yatho/demo-angular-migrationpublic Fork
⑂ enterprise-version
Commits
⬇ Clone ▾
git clone https://git.rickub.com/yatho/demo-angular-migration.git
git clone ssh://git@rickub.com/yatho/demo-angular-migration.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

test #1

Open@yatho wants to merge enterprise-version into main
20 files changed
added .github/workflows/ci.yml +88 -0
new file mode 100644
@@ -0,0 +1,88 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches: [enterprise-version]
6+ pull_request:
7+
8+jobs:
9+ build:
10+ name: Build
11+ runs-on: ubuntu-latest
12+ steps:
13+ - name: Checkout road-trip
14+ uses: actions/checkout@v4
15+ with:
16+ path: roadTrip
17+
18+ # road-trip depends on ../ui-kit/dist/ui-kit (pnpm link)
19+ - name: Checkout ui-kit
20+ env:
21+ UIKIT_REPO_URL: https://rickub.com/yatho/demo-angular-migration-uikit.git
22+ run: git clone --depth 1 "$UIKIT_REPO_URL" ui-kit
23+
24+ - uses: pnpm/action-setup@v4
25+ with:
26+ version: 9
27+
28+ - uses: actions/setup-node@v4
29+ with:
30+ node-version: 20
31+ cache: pnpm
32+ cache-dependency-path: |
33+ roadTrip/pnpm-lock.yaml
34+ ui-kit/pnpm-lock.yaml
35+
36+ - name: Build ui-kit
37+ working-directory: ui-kit
38+ run: |
39+ pnpm install --frozen-lockfile
40+ pnpm build
41+
42+ - name: Install road-trip
43+ working-directory: roadTrip
44+ run: pnpm install --frozen-lockfile
45+
46+ - name: Build road-trip
47+ working-directory: roadTrip
48+ run: pnpm build
49+
50+ test:
51+ name: Test
52+ runs-on: ubuntu-latest
53+ steps:
54+ - name: Checkout road-trip
55+ uses: actions/checkout@v4
56+ with:
57+ path: roadTrip
58+
59+ - name: Checkout ui-kit
60+ env:
61+ UIKIT_REPO_URL: https://rickub.com/yatho/demo-angular-migration-uikit.git
62+ run: git clone --depth 1 "$UIKIT_REPO_URL" ui-kit
63+
64+ - uses: pnpm/action-setup@v4
65+ with:
66+ version: 9
67+
68+ - uses: actions/setup-node@v4
69+ with:
70+ node-version: 20
71+ cache: pnpm
72+ cache-dependency-path: |
73+ roadTrip/pnpm-lock.yaml
74+ ui-kit/pnpm-lock.yaml
75+
76+ - name: Build ui-kit
77+ working-directory: ui-kit
78+ run: |
79+ pnpm install --frozen-lockfile
80+ pnpm build
81+
82+ - name: Install road-trip
83+ working-directory: roadTrip
84+ run: pnpm install --frozen-lockfile
85+
86+ - name: Test road-trip
87+ working-directory: roadTrip
88+ run: pnpm test --no-watch --karma-config=karma.ci.conf.js
new file mode 100644
@@ -0,0 +1,88 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches: [enterprise-version]
6+ pull_request:
7+
8+jobs:
9+ build:
10+ name: Build
11+ runs-on: ubuntu-latest
12+ steps:
13+ - name: Checkout road-trip
14+ uses: actions/checkout@v4
15+ with:
16+ path: roadTrip
17+
18+ # road-trip depends on ../ui-kit/dist/ui-kit (pnpm link)
19+ - name: Checkout ui-kit
20+ env:
21+ UIKIT_REPO_URL: https://rickub.com/yatho/demo-angular-migration-uikit.git
22+ run: git clone --depth 1 "$UIKIT_REPO_URL" ui-kit
23+
24+ - uses: pnpm/action-setup@v4
25+ with:
26+ version: 9
27+
28+ - uses: actions/setup-node@v4
29+ with:
30+ node-version: 20
31+ cache: pnpm
32+ cache-dependency-path: |
33+ roadTrip/pnpm-lock.yaml
34+ ui-kit/pnpm-lock.yaml
35+
36+ - name: Build ui-kit
37+ working-directory: ui-kit
38+ run: |
39+ pnpm install --frozen-lockfile
40+ pnpm build
41+
42+ - name: Install road-trip
43+ working-directory: roadTrip
44+ run: pnpm install --frozen-lockfile
45+
46+ - name: Build road-trip
47+ working-directory: roadTrip
48+ run: pnpm build
49+
50+ test:
51+ name: Test
52+ runs-on: ubuntu-latest
53+ steps:
54+ - name: Checkout road-trip
55+ uses: actions/checkout@v4
56+ with:
57+ path: roadTrip
58+
59+ - name: Checkout ui-kit
60+ env:
61+ UIKIT_REPO_URL: https://rickub.com/yatho/demo-angular-migration-uikit.git
62+ run: git clone --depth 1 "$UIKIT_REPO_URL" ui-kit
63+
64+ - uses: pnpm/action-setup@v4
65+ with:
66+ version: 9
67+
68+ - uses: actions/setup-node@v4
69+ with:
70+ node-version: 20
71+ cache: pnpm
72+ cache-dependency-path: |
73+ roadTrip/pnpm-lock.yaml
74+ ui-kit/pnpm-lock.yaml
75+
76+ - name: Build ui-kit
77+ working-directory: ui-kit
78+ run: |
79+ pnpm install --frozen-lockfile
80+ pnpm build
81+
82+ - name: Install road-trip
83+ working-directory: roadTrip
84+ run: pnpm install --frozen-lockfile
85+
86+ - name: Test road-trip
87+ working-directory: roadTrip
88+ run: pnpm test --no-watch --karma-config=karma.ci.conf.js
modified README.md +4 -0
@@ -25,3 +25,7 @@ Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To u
2525 ## Further help
2626
2727 To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
28+
29+## ui-kit
30+
31+`ui-kit` is linked from `../ui-kit/dist/ui-kit` (temporary, until published). Build it first with `pnpm ui-kit:build` before `pnpm start`, `pnpm build` or `pnpm test`.
@@ -25,3 +25,7 @@ Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To u
25 ## Further help25 ## Further help
26 26
27 To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.27 To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
28+
29+## ui-kit
30+
31+`ui-kit` is linked from `../ui-kit/dist/ui-kit` (temporary, until published). Build it first with `pnpm ui-kit:build` before `pnpm start`, `pnpm build` or `pnpm test`.
modified angular.json +4 -2
@@ -35,7 +35,8 @@
3535 "scripts": [],
3636 "allowedCommonJsDependencies": [
3737 "leaflet"
38- ]
38+ ],
39+ "preserveSymlinks": true
3940 },
4041 "configurations": {
4142 "production": {
@@ -93,7 +94,8 @@
9394 "styles": [
9495 "src/styles.css"
9596 ],
96- "scripts": []
97+ "scripts": [],
98+ "preserveSymlinks": true
9799 }
98100 }
99101 }
@@ -35,7 +35,8 @@
35 "scripts": [],35 "scripts": [],
36 "allowedCommonJsDependencies": [36 "allowedCommonJsDependencies": [
37 "leaflet"37 "leaflet"
38- ]38+ ],
39+ "preserveSymlinks": true
39 },40 },
40 "configurations": {41 "configurations": {
41 "production": {42 "production": {
@@ -93,7 +94,8 @@
93 "styles": [94 "styles": [
94 "src/styles.css"95 "src/styles.css"
95 ],96 ],
96- "scripts": []97+ "scripts": [],
98+ "preserveSymlinks": true
97 }99 }
98 }100 }
99 }101 }
added karma.ci.conf.js +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+module.exports = function (config) {
2+ config.set({
3+ basePath: '',
4+ frameworks: ['jasmine', '@angular-devkit/build-angular'],
5+ plugins: [
6+ require('karma-jasmine'),
7+ require('karma-chrome-launcher'),
8+ require('karma-coverage'),
9+ require('@angular-devkit/build-angular/plugins/karma'),
10+ ],
11+ reporters: ['progress'],
12+ browsers: ['ChromeHeadlessCI'],
13+ customLaunchers: {
14+ ChromeHeadlessCI: {
15+ base: 'ChromeHeadless',
16+ flags: ['--no-sandbox', '--disable-gpu'],
17+ },
18+ },
19+ singleRun: true,
20+ restartOnFileChange: false,
21+ });
22+};
new file mode 100644
@@ -0,0 +1,22 @@
1+module.exports = function (config) {
2+ config.set({
3+ basePath: '',
4+ frameworks: ['jasmine', '@angular-devkit/build-angular'],
5+ plugins: [
6+ require('karma-jasmine'),
7+ require('karma-chrome-launcher'),
8+ require('karma-coverage'),
9+ require('@angular-devkit/build-angular/plugins/karma'),
10+ ],
11+ reporters: ['progress'],
12+ browsers: ['ChromeHeadlessCI'],
13+ customLaunchers: {
14+ ChromeHeadlessCI: {
15+ base: 'ChromeHeadless',
16+ flags: ['--no-sandbox', '--disable-gpu'],
17+ },
18+ },
19+ singleRun: true,
20+ restartOnFileChange: false,
21+ });
22+};
modified package.json +2 -0
@@ -3,6 +3,7 @@
33 "version": "0.0.0",
44 "scripts": {
55 "ng": "ng",
6+ "ui-kit:build": "pnpm --dir ../ui-kit build",
67 "start": "ng serve",
78 "build": "ng build",
89 "watch": "ng build --watch --configuration development",
@@ -21,6 +22,7 @@
2122 "leaflet": "^1.9.4",
2223 "rxjs": "~7.8.0",
2324 "tslib": "^2.3.0",
25+ "ui-kit": "link:../ui-kit/dist/ui-kit",
2426 "zone.js": "~0.14.10"
2527 },
2628 "devDependencies": {
@@ -3,6 +3,7 @@
3 "version": "0.0.0",3 "version": "0.0.0",
4 "scripts": {4 "scripts": {
5 "ng": "ng",5 "ng": "ng",
6+ "ui-kit:build": "pnpm --dir ../ui-kit build",
6 "start": "ng serve",7 "start": "ng serve",
7 "build": "ng build",8 "build": "ng build",
8 "watch": "ng build --watch --configuration development",9 "watch": "ng build --watch --configuration development",
@@ -21,6 +22,7 @@
21 "leaflet": "^1.9.4",22 "leaflet": "^1.9.4",
22 "rxjs": "~7.8.0",23 "rxjs": "~7.8.0",
23 "tslib": "^2.3.0",24 "tslib": "^2.3.0",
25+ "ui-kit": "link:../ui-kit/dist/ui-kit",
24 "zone.js": "~0.14.10"26 "zone.js": "~0.14.10"
25 },27 },
26 "devDependencies": {28 "devDependencies": {
modified pnpm-lock.yaml +3 -0
@@ -41,6 +41,9 @@ importers:
4141 tslib:
4242 specifier: ^2.3.0
4343 version: 2.8.1
44+ ui-kit:
45+ specifier: link:../ui-kit/dist/ui-kit
46+ version: link:../ui-kit/dist/ui-kit
4447 zone.js:
4548 specifier: ~0.14.10
4649 version: 0.14.10
@@ -41,6 +41,9 @@ importers:
41 tslib:41 tslib:
42 specifier: ^2.3.042 specifier: ^2.3.0
43 version: 2.8.143 version: 2.8.1
44+ ui-kit:
45+ specifier: link:../ui-kit/dist/ui-kit
46+ version: link:../ui-kit/dist/ui-kit
44 zone.js:47 zone.js:
45 specifier: ~0.14.1048 specifier: ~0.14.10
46 version: 0.14.1049 version: 0.14.10
modified src/app/app.component.css +1 -1
@@ -48,7 +48,7 @@ nav a:hover {
4848
4949 nav a.is-active {
5050 background: var(--visited);
51- color: var(--on-accent);
51+ color: var(--ui-on-accent);
Info: a non-blocking note Athena Oct 11, 2026 Permalink to @athena's comment

The color token changes from --on-accent to --ui-on-accent. Make sure this variable is actually provided by ui-kit's styles (and that those styles are loaded globally), otherwise the active nav link will fall back to an inherited/inherited-contrast color.

5252 }
5353
5454 .count {
@@ -48,7 +48,7 @@ nav a:hover {
48 48
49 nav a.is-active {49 nav a.is-active {
50 background: var(--visited);50 background: var(--visited);
51- color: var(--on-accent);51+ color: var(--ui-on-accent);
52 }52 }
53 53
54 .count {54 .count {
deleted src/app/travel/components/rating/rating.component.ts +0 -29
deleted file mode 100644
@@ -1,29 +0,0 @@
1-import { NgIf } from '@angular/common';
2-import { Component, Input } from '@angular/core';
3-
4-@Component({
5- selector: 'app-rating',
6- standalone: true,
7- imports: [NgIf],
8- template: `
9- <span *ngIf="value !== undefined; else noRating" class="rating" role="img" [attr.aria-label]="value + ' sur 5'">{{
10- stars
11- }}</span>
12- <ng-template #noRating><span class="muted">Non noté</span></ng-template>
13- `,
14- styles: `
15- .rating {
16- color: var(--wish);
17- letter-spacing: 0.1em;
18- white-space: nowrap;
19- }
20- `,
21-})
22-export class RatingComponent {
23- @Input() value?: number;
24-
25- get stars(): string {
26- const filled = Math.max(0, Math.min(5, Math.round(this.value || 0)));
27- return '★'.repeat(filled) + '☆'.repeat(5 - filled);
28- }
29-}
deleted file mode 100644
@@ -1,29 +0,0 @@
1-import { NgIf } from '@angular/common';
2-import { Component, Input } from '@angular/core';
3-
4-@Component({
5- selector: 'app-rating',
6- standalone: true,
7- imports: [NgIf],
8- template: `
9- <span *ngIf="value !== undefined; else noRating" class="rating" role="img" [attr.aria-label]="value + ' sur 5'">{{
10- stars
11- }}</span>
12- <ng-template #noRating><span class="muted">Non noté</span></ng-template>
13- `,
14- styles: `
15- .rating {
16- color: var(--wish);
17- letter-spacing: 0.1em;
18- white-space: nowrap;
19- }
20- `,
21-})
22-export class RatingComponent {
23- @Input() value?: number;
24-
25- get stars(): string {
26- const filled = Math.max(0, Math.min(5, Math.round(this.value || 0)));
27- return '★'.repeat(filled) + '☆'.repeat(5 - filled);
28- }
29-}
modified src/app/travel/components/travel-map/travel-map.component.spec.ts +5 -5
@@ -39,9 +39,9 @@ describe('TravelMapComponent', () => {
3939 fixture.componentRef.setInput('places', places);
4040 const root = render();
4141
42- expect(root.querySelectorAll('.pin').length).toBe(2);
43- expect(root.querySelectorAll('.pin--visited').length).toBe(1);
44- expect(root.querySelectorAll('.pin--wishlist').length).toBe(1);
42+ expect(root.querySelectorAll('.ui-map-pin').length).toBe(2);
43+ expect(root.querySelectorAll('.ui-map-pin--primary').length).toBe(1);
44+ expect(root.querySelectorAll('.ui-map-pin--secondary').length).toBe(1);
4545 });
4646
4747 it('met à jour les repères quand la liste change', () => {
@@ -51,12 +51,12 @@ describe('TravelMapComponent', () => {
5151 fixture.componentRef.setInput('places', places.slice(0, 1));
5252 fixture.detectChanges();
5353
54- expect(root.querySelectorAll('.pin').length).toBe(1);
54+ expect(root.querySelectorAll('.ui-map-pin').length).toBe(1);
5555 });
5656
5757 it('affiche le point à placer', () => {
5858 fixture.componentRef.setInput('picked', { lat: 10, lng: 20 });
59- expect(render().querySelectorAll('.pin--picked').length).toBe(1);
59+ expect(render().querySelectorAll('.ui-map-pin--highlight').length).toBe(1);
6060 });
6161
6262 it('émet les coordonnées au clic quand la sélection est activée', () => {
@@ -39,9 +39,9 @@ describe('TravelMapComponent', () => {
39 fixture.componentRef.setInput('places', places);39 fixture.componentRef.setInput('places', places);
40 const root = render();40 const root = render();
41 41
42- expect(root.querySelectorAll('.pin').length).toBe(2);42+ expect(root.querySelectorAll('.ui-map-pin').length).toBe(2);
43- expect(root.querySelectorAll('.pin--visited').length).toBe(1);43+ expect(root.querySelectorAll('.ui-map-pin--primary').length).toBe(1);
44- expect(root.querySelectorAll('.pin--wishlist').length).toBe(1);44+ expect(root.querySelectorAll('.ui-map-pin--secondary').length).toBe(1);
45 });45 });
46 46
47 it('met à jour les repères quand la liste change', () => {47 it('met à jour les repères quand la liste change', () => {
@@ -51,12 +51,12 @@ describe('TravelMapComponent', () => {
51 fixture.componentRef.setInput('places', places.slice(0, 1));51 fixture.componentRef.setInput('places', places.slice(0, 1));
52 fixture.detectChanges();52 fixture.detectChanges();
53 53
54- expect(root.querySelectorAll('.pin').length).toBe(1);54+ expect(root.querySelectorAll('.ui-map-pin').length).toBe(1);
55 });55 });
56 56
57 it('affiche le point à placer', () => {57 it('affiche le point à placer', () => {
58 fixture.componentRef.setInput('picked', { lat: 10, lng: 20 });58 fixture.componentRef.setInput('picked', { lat: 10, lng: 20 });
59- expect(render().querySelectorAll('.pin--picked').length).toBe(1);59+ expect(render().querySelectorAll('.ui-map-pin--highlight').length).toBe(1);
60 });60 });
61 61
62 it('émet les coordonnées au clic quand la sélection est activée', () => {62 it('émet les coordonnées au clic quand la sélection est activée', () => {
modified src/app/travel/components/travel-map/travel-map.component.ts +28 -143
@@ -1,60 +1,30 @@
1-import {
2- AfterViewInit,
3- Component,
4- ElementRef,
5- EventEmitter,
6- Input,
7- OnChanges,
8- OnDestroy,
9- Output,
10- SimpleChanges,
11- ViewChild,
12- ViewEncapsulation,
13-} from '@angular/core';
14-import * as L from 'leaflet';
1+import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
2+import { UiMapComponent, UiMapPoint } from 'ui-kit';
153 import { CATEGORY_LABELS, LatLng, Place } from '../../models/place.model';
164 import { formatIsoDate } from '../../utils/date';
175
18-type PinKind = Place['status'] | 'picked';
19-
6+/** Adaptateur entre le domaine RoadTrip (lieux) et la carte générique de la bibliothèque ui-kit. */
207 @Component({
218 selector: 'app-travel-map',
229 standalone: true,
23- // Les repères sont créés par Leaflet hors du template : leurs styles doivent être globaux.
24- encapsulation: ViewEncapsulation.None,
25- template: `<div #surface class="travel-map" role="region" [attr.aria-label]="label"></div>`,
10+ imports: [UiMapComponent],
11+ template: `
12+ <ui-map
13+ [points]="points"
14+ [selectable]="pickable"
Info: a non-blocking note Athena Oct 11, 2026 Permalink to @athena's comment

The previous drawPlaces() auto-fitted bounds to the markers when not pickable (with padding and maxZoom 5). Confirm UiMapComponent preserves this viewport behavior, or the initial map view will change for users with existing places.

15+ [selected]="picked"
16+ [label]="label"
17+ (pointSelected)="locationPicked.emit($event)"
18+ ></ui-map>
Info: a non-blocking note Athena Oct 11, 2026 Permalink to @athena's comment

pointSelected's $event is forwarded straight to locationPicked: EventEmitter. Confirm UiMapComponent emits a plain {lat, lng} and not the full UiMapPoint — the old code also rounded coordinates to 4 decimals; that normalization is now gone. The spec only asserts shape ({lat, lng} numbers), so a mismatch here would slip through tests.

Info: a non-blocking note Athena Oct 11, 2026 Permalink to @athena's comment

The old implementation rounded emitted pick coordinates to 4 decimals (round()); that behavior now depends on UiMapComponent's pointSelected event. If ui-kit emits raw lat/lng, picked values will carry full float precision — worth confirming this is intended.

19+ `,
2620 styles: `
27- app-travel-map {
21+ :host {
2822 display: block;
2923 height: 100%;
3024 }
31- .travel-map {
32- position: relative;
33- z-index: 0;
34- height: 100%;
35- min-height: 320px;
36- border-radius: var(--radius);
37- }
38- .pin {
39- box-sizing: border-box;
40- border: 3px solid #fff;
41- border-radius: 50%;
42- box-shadow: 0 1px 5px rgb(0 0 0 / 0.5);
43- }
44- .pin--visited {
45- background: var(--visited);
46- }
47- .pin--wishlist {
48- background: #fff;
49- border: 3px dashed var(--wish);
50- }
51- .pin--picked {
52- background: #111;
53- box-shadow: 0 0 0 4px rgb(255 255 255 / 0.85), 0 1px 6px rgb(0 0 0 / 0.6);
54- }
5525 `,
5626 })
57-export class TravelMapComponent implements OnChanges, AfterViewInit, OnDestroy {
27+export class TravelMapComponent implements OnChanges {
5828 @Input() places: readonly Place[] = [];
5929 /** Active la sélection d'un point par clic sur la carte. */
6030 @Input() pickable = false;
@@ -64,108 +34,23 @@ export class TravelMapComponent implements OnChanges, AfterViewInit, OnDestroy {
6434
6535 @Output() locationPicked = new EventEmitter<LatLng>();
6636
67- @ViewChild('surface', { static: true }) surface!: ElementRef<HTMLDivElement>;
68-
69- private readonly markers = L.layerGroup();
70- private map: L.Map | undefined;
71- private pickedMarker: L.Marker | undefined;
37+ points: UiMapPoint[] = [];
7238
73- ngOnChanges(changes: SimpleChanges): void {
74- // Avant ngAfterViewInit, la carte n'existe pas encore : le premier dessin y est fait.
75- if (!this.map) {
76- return;
77- }
78- if (changes['places']) {
79- this.drawPlaces(this.places);
80- }
81- if (changes['picked']) {
82- this.drawPicked(this.picked);
83- }
39+ ngOnChanges(): void {
40+ this.points = this.places.map(toPoint);
8441 }
85-
86- ngAfterViewInit(): void {
87- const map = L.map(this.surface.nativeElement, { worldCopyJump: true, minZoom: 2 }).setView([30, 10], 2);
88-
89- L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
90- maxZoom: 18,
91- attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
92- }).addTo(map);
93- this.markers.addTo(map);
94-
95- map.on('click', (event: L.LeafletMouseEvent) => {
96- if (this.pickable) {
97- const { lat, lng } = event.latlng.wrap();
98- this.locationPicked.emit({ lat: round(lat), lng: round(lng) });
99- }
100- });
101-
102- this.map = map;
103- this.drawPlaces(this.places);
104- this.drawPicked(this.picked);
105- }
106-
107- ngOnDestroy(): void {
108- this.map?.remove();
109- this.map = undefined;
110- }
111-
112- private drawPlaces(places: readonly Place[]): void {
113- this.markers.clearLayers();
114-
115- for (const place of places) {
116- L.marker([place.lat, place.lng], { icon: pinIcon(place.status), title: place.name })
117- .bindPopup(popupContent(place))
118- .addTo(this.markers);
119- }
120-
121- if (!this.pickable && places.length > 0) {
122- const bounds = L.latLngBounds(places.map((place): L.LatLngTuple => [place.lat, place.lng]));
123- // Marge plus large en haut à gauche pour ne pas passer sous les boutons de zoom.
124- this.map?.fitBounds(bounds, { paddingTopLeft: [64, 64], paddingBottomRight: [40, 40], maxZoom: 5 });
125- }
126- }
127-
128- private drawPicked(point: LatLng | null): void {
129- this.pickedMarker?.remove();
130- this.pickedMarker = undefined;
131-
132- if (point && this.map) {
133- this.pickedMarker = L.marker([point.lat, point.lng], {
134- icon: pinIcon('picked'),
135- interactive: false,
136- keyboard: false,
137- }).addTo(this.map);
138- this.map.panTo([point.lat, point.lng]);
139- }
140- }
141-}
142-
143-function pinIcon(kind: PinKind): L.DivIcon {
144- return L.divIcon({
145- className: `pin pin--${kind}`,
146- iconSize: [18, 18],
147- iconAnchor: [9, 9],
148- popupAnchor: [0, -10],
149- });
15042 }
15143
152-/** Contenu de la bulle construit via le DOM : les saisies de l'utilisateur ne sont jamais interprétées comme du HTML. */
153-function popupContent(place: Place): HTMLElement {
154- const root = document.createElement('div');
155- const title = document.createElement('strong');
156- title.textContent = place.name;
157-
158- const meta = document.createElement('div');
159- meta.textContent = `${place.country} · ${CATEGORY_LABELS[place.category]}`;
160-
161- const status = document.createElement('div');
162- status.textContent =
44+function toPoint(place: Place): UiMapPoint {
45+ const status =
16346 place.status === 'visited' && place.visitedOn ? `Visité le ${formatIsoDate(place.visitedOn)}` : 'À visiter';
16447
165- root.append(title, meta, status);
166- return root;
167-}
168-
169-function round(value: number): number {
170- return Math.round(value * 1e4) / 1e4;
48+ return {
49+ id: place.id,
Info: a non-blocking note Athena Oct 11, 2026 Permalink to @athena's comment

The old implementation auto-fitted bounds to the places and panned to the picked point; the new adapter only forwards points/selected. If UiMapComponent doesn't implement equivalent fit/pan behavior, this is a user-visible regression (map stays at default world view after adding places). Please confirm the library handles it.

50+ lat: place.lat,
51+ lng: place.lng,
52+ kind: place.status === 'visited' ? 'primary' : 'secondary',
53+ title: place.name,
54+ details: [`${place.country} · ${CATEGORY_LABELS[place.category]}`, status],
55+ };
17156 }
@@ -1,60 +1,30 @@
1-import {1+import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
2- AfterViewInit,2+import { UiMapComponent, UiMapPoint } from 'ui-kit';
3- Component,
4- ElementRef,
5- EventEmitter,
6- Input,
7- OnChanges,
8- OnDestroy,
9- Output,
10- SimpleChanges,
11- ViewChild,
12- ViewEncapsulation,
13-} from '@angular/core';
14-import * as L from 'leaflet';
15 import { CATEGORY_LABELS, LatLng, Place } from '../../models/place.model';3 import { CATEGORY_LABELS, LatLng, Place } from '../../models/place.model';
16 import { formatIsoDate } from '../../utils/date';4 import { formatIsoDate } from '../../utils/date';
17 5
18-type PinKind = Place['status'] | 'picked';6+/** Adaptateur entre le domaine RoadTrip (lieux) et la carte générique de la bibliothèque ui-kit. */
19-
20 @Component({7 @Component({
21 selector: 'app-travel-map',8 selector: 'app-travel-map',
22 standalone: true,9 standalone: true,
23- // Les repères sont créés par Leaflet hors du template : leurs styles doivent être globaux.10+ imports: [UiMapComponent],
24- encapsulation: ViewEncapsulation.None,11+ template: `
25- template: `<div #surface class="travel-map" role="region" [attr.aria-label]="label"></div>`,12+ <ui-map
13+ [points]="points"
14+ [selectable]="pickable"
15+ [selected]="picked"
16+ [label]="label"
17+ (pointSelected)="locationPicked.emit($event)"
18+ ></ui-map>
19+ `,
26 styles: `20 styles: `
27- app-travel-map {21+ :host {
28 display: block;22 display: block;
29 height: 100%;23 height: 100%;
30 }24 }
31- .travel-map {
32- position: relative;
33- z-index: 0;
34- height: 100%;
35- min-height: 320px;
36- border-radius: var(--radius);
37- }
38- .pin {
39- box-sizing: border-box;
40- border: 3px solid #fff;
41- border-radius: 50%;
42- box-shadow: 0 1px 5px rgb(0 0 0 / 0.5);
43- }
44- .pin--visited {
45- background: var(--visited);
46- }
47- .pin--wishlist {
48- background: #fff;
49- border: 3px dashed var(--wish);
50- }
51- .pin--picked {
52- background: #111;
53- box-shadow: 0 0 0 4px rgb(255 255 255 / 0.85), 0 1px 6px rgb(0 0 0 / 0.6);
54- }
55 `,25 `,
56 })26 })
57-export class TravelMapComponent implements OnChanges, AfterViewInit, OnDestroy {27+export class TravelMapComponent implements OnChanges {
58 @Input() places: readonly Place[] = [];28 @Input() places: readonly Place[] = [];
59 /** Active la sélection d'un point par clic sur la carte. */29 /** Active la sélection d'un point par clic sur la carte. */
60 @Input() pickable = false;30 @Input() pickable = false;
@@ -64,108 +34,23 @@ export class TravelMapComponent implements OnChanges, AfterViewInit, OnDestroy {
64 34
65 @Output() locationPicked = new EventEmitter<LatLng>();35 @Output() locationPicked = new EventEmitter<LatLng>();
66 36
67- @ViewChild('surface', { static: true }) surface!: ElementRef<HTMLDivElement>;37+ points: UiMapPoint[] = [];
68-
69- private readonly markers = L.layerGroup();
70- private map: L.Map | undefined;
71- private pickedMarker: L.Marker | undefined;
72 38
73- ngOnChanges(changes: SimpleChanges): void {39+ ngOnChanges(): void {
74- // Avant ngAfterViewInit, la carte n'existe pas encore : le premier dessin y est fait.40+ this.points = this.places.map(toPoint);
75- if (!this.map) {
76- return;
77- }
78- if (changes['places']) {
79- this.drawPlaces(this.places);
80- }
81- if (changes['picked']) {
82- this.drawPicked(this.picked);
83- }
84 }41 }
85-
86- ngAfterViewInit(): void {
87- const map = L.map(this.surface.nativeElement, { worldCopyJump: true, minZoom: 2 }).setView([30, 10], 2);
88-
89- L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
90- maxZoom: 18,
91- attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
92- }).addTo(map);
93- this.markers.addTo(map);
94-
95- map.on('click', (event: L.LeafletMouseEvent) => {
96- if (this.pickable) {
97- const { lat, lng } = event.latlng.wrap();
98- this.locationPicked.emit({ lat: round(lat), lng: round(lng) });
99- }
100- });
101-
102- this.map = map;
103- this.drawPlaces(this.places);
104- this.drawPicked(this.picked);
105- }
106-
107- ngOnDestroy(): void {
108- this.map?.remove();
109- this.map = undefined;
110- }
111-
112- private drawPlaces(places: readonly Place[]): void {
113- this.markers.clearLayers();
114-
115- for (const place of places) {
116- L.marker([place.lat, place.lng], { icon: pinIcon(place.status), title: place.name })
117- .bindPopup(popupContent(place))
118- .addTo(this.markers);
119- }
120-
121- if (!this.pickable && places.length > 0) {
122- const bounds = L.latLngBounds(places.map((place): L.LatLngTuple => [place.lat, place.lng]));
123- // Marge plus large en haut à gauche pour ne pas passer sous les boutons de zoom.
124- this.map?.fitBounds(bounds, { paddingTopLeft: [64, 64], paddingBottomRight: [40, 40], maxZoom: 5 });
125- }
126- }
127-
128- private drawPicked(point: LatLng | null): void {
129- this.pickedMarker?.remove();
130- this.pickedMarker = undefined;
131-
132- if (point && this.map) {
133- this.pickedMarker = L.marker([point.lat, point.lng], {
134- icon: pinIcon('picked'),
135- interactive: false,
136- keyboard: false,
137- }).addTo(this.map);
138- this.map.panTo([point.lat, point.lng]);
139- }
140- }
141-}
142-
143-function pinIcon(kind: PinKind): L.DivIcon {
144- return L.divIcon({
145- className: `pin pin--${kind}`,
146- iconSize: [18, 18],
147- iconAnchor: [9, 9],
148- popupAnchor: [0, -10],
149- });
150 }42 }
151 43
152-/** Contenu de la bulle construit via le DOM : les saisies de l'utilisateur ne sont jamais interprétées comme du HTML. */44+function toPoint(place: Place): UiMapPoint {
153-function popupContent(place: Place): HTMLElement {45+ const status =
154- const root = document.createElement('div');
155- const title = document.createElement('strong');
156- title.textContent = place.name;
157-
158- const meta = document.createElement('div');
159- meta.textContent = `${place.country} · ${CATEGORY_LABELS[place.category]}`;
160-
161- const status = document.createElement('div');
162- status.textContent =
163 place.status === 'visited' && place.visitedOn ? `Visité le ${formatIsoDate(place.visitedOn)}` : 'À visiter';46 place.status === 'visited' && place.visitedOn ? `Visité le ${formatIsoDate(place.visitedOn)}` : 'À visiter';
164 47
165- root.append(title, meta, status);48+ return {
166- return root;49+ id: place.id,
167-}50+ lat: place.lat,
168-51+ lng: place.lng,
169-function round(value: number): number {52+ kind: place.status === 'visited' ? 'primary' : 'secondary',
170- return Math.round(value * 1e4) / 1e4;53+ title: place.name,
54+ details: [`${place.country} · ${CATEGORY_LABELS[place.category]}`, status],
55+ };
171 }56 }
modified src/app/travel/pages/place-form-page/place-form-page.component.html +87 -113
@@ -1,134 +1,108 @@
11 <section class="page">
2- <header class="page__header">
3- <p class="eyebrow">Nouveau lieu</p>
4- <h1>Ajouter un lieu à votre carnet</h1>
5- </header>
2+ <ui-page-header eyebrow="Nouveau lieu" heading="Ajouter un lieu à votre carnet"></ui-page-header>
63
74 <div class="layout">
8- <form class="card form" [formGroup]="form" (ngSubmit)="submit()" novalidate>
9- <fieldset class="segmented">
10- <legend>Ce lieu est…</legend>
11- <label>
12- <input type="radio" formControlName="status" value="visited" />
13- <span>Déjà visité</span>
14- </label>
15- <label>
16- <input type="radio" formControlName="status" value="wishlist" />
17- <span>Sur ma liste de souhaits</span>
18- </label>
19- </fieldset>
5+ <ui-card>
6+ <form class="form" [formGroup]="form" (ngSubmit)="submit()" novalidate>
7+ <ui-segmented legend="Ce lieu est…" formControlName="status" [options]="statusOptions"></ui-segmented>
208
21- <div class="field">
22- <label for="name">Nom du lieu</label>
23- <input
24- id="name"
25- type="text"
26- formControlName="name"
27- autocomplete="off"
28- placeholder="Annecy, Col de l’Iseran…"
29- [attr.aria-describedby]="errorOf('name') ? 'name-error' : null"
30- />
31- <p *ngIf="errorOf('name') as message" class="field__error" id="name-error" role="alert">{{ message }}</p>
32- </div>
33-
34- <div class="row">
35- <div class="field">
36- <label for="country">Pays</label>
9+ <ui-field label="Nom du lieu" controlId="name" [error]="errorOf('name')">
3710 <input
38- id="country"
11+ id="name"
3912 type="text"
40- formControlName="country"
41- autocomplete="country-name"
42- [attr.aria-describedby]="errorOf('country') ? 'country-error' : null"
13+ formControlName="name"
14+ autocomplete="off"
15+ placeholder="Annecy, Col de l’Iseran…"
16+ [attr.aria-describedby]="errorOf('name') ? 'name-error' : null"
Info: a non-blocking note Athena Oct 11, 2026 Permalink to @athena's comment

Les inputs conservent [attr.aria-describedby]="errorOf('name') ? 'name-error' : null" alors que le rendu de l'erreur a été délégué à ui-field via [error]. Si ui-field relie déjà l'input au message (via controlId) et génère son propre id, ce binding est redondant ; si l'id généré diffère de '-error', le lien aria devient cassé. Vérifier que les ids produits par ui-field correspondent bien à ce qui est référencé ici.

4317 />
44- <p *ngIf="errorOf('country') as message" class="field__error" id="country-error" role="alert">{{ message }}</p>
45- </div>
18+ </ui-field>
4619
47- <div class="field">
48- <label for="category">Type de lieu</label>
49- <select id="category" formControlName="category">
50- <option *ngFor="let category of categories" [ngValue]="category">{{ categoryLabels[category] }}</option>
51- </select>
52- </div>
53- </div>
20+ <div class="row">
21+ <ui-field label="Pays" controlId="country" [error]="errorOf('country')">
22+ <input
23+ id="country"
24+ type="text"
25+ formControlName="country"
26+ autocomplete="country-name"
27+ [attr.aria-describedby]="errorOf('country') ? 'country-error' : null"
28+ />
29+ </ui-field>
5430
55- <div class="row">
56- <div class="field">
57- <label for="lat">Latitude</label>
58- <input
59- id="lat"
60- type="number"
61- step="any"
62- inputmode="decimal"
63- formControlName="lat"
64- [attr.aria-describedby]="errorOf('lat') ? 'lat-error' : null"
65- />
66- <p *ngIf="errorOf('lat') as message" class="field__error" id="lat-error" role="alert">{{ message }}</p>
31+ <ui-field label="Type de lieu" controlId="category">
32+ <select id="category" formControlName="category">
33+ <option *ngFor="let category of categories" [ngValue]="category">{{ categoryLabels[category] }}</option>
34+ </select>
35+ </ui-field>
6736 </div>
6837
69- <div class="field">
70- <label for="lng">Longitude</label>
71- <input
72- id="lng"
73- type="number"
74- step="any"
75- inputmode="decimal"
76- formControlName="lng"
77- [attr.aria-describedby]="errorOf('lng') ? 'lng-error' : null"
78- />
79- <p *ngIf="errorOf('lng') as message" class="field__error" id="lng-error" role="alert">{{ message }}</p>
80- </div>
81- </div>
82- <p class="hint">Astuce : cliquez sur la carte pour remplir la latitude et la longitude.</p>
38+ <div class="row">
39+ <ui-field label="Latitude" controlId="lat" [error]="errorOf('lat')">
40+ <input
41+ id="lat"
42+ type="number"
43+ step="any"
44+ inputmode="decimal"
45+ formControlName="lat"
46+ [attr.aria-describedby]="errorOf('lat') ? 'lat-error' : null"
47+ />
48+ </ui-field>
8349
84- <ng-container [ngSwitch]="status">
85- <div class="row" *ngSwitchCase="'visited'">
86- <div class="field">
87- <label for="visitedOn">Date de visite</label>
50+ <ui-field label="Longitude" controlId="lng" [error]="errorOf('lng')">
8851 <input
89- id="visitedOn"
90- type="date"
91- formControlName="visitedOn"
92- [attr.max]="maxDate"
93- [attr.aria-describedby]="errorOf('visitedOn') ? 'visitedOn-error' : null"
52+ id="lng"
53+ type="number"
54+ step="any"
55+ inputmode="decimal"
56+ formControlName="lng"
57+ [attr.aria-describedby]="errorOf('lng') ? 'lng-error' : null"
9458 />
95- <p *ngIf="errorOf('visitedOn') as message" class="field__error" id="visitedOn-error" role="alert">{{ message }}</p>
96- </div>
59+ </ui-field>
60+ </div>
61+ <p class="hint">Astuce : cliquez sur la carte pour remplir la latitude et la longitude.</p>
9762
98- <div class="field">
99- <label for="rating">Note (facultative)</label>
100- <select id="rating" formControlName="rating">
101- <option [ngValue]="null">Pas de note</option>
102- <option *ngFor="let rating of ratings" [ngValue]="rating">{{ rating }} sur 5</option>
103- </select>
63+ <ng-container [ngSwitch]="status">
64+ <div class="row" *ngSwitchCase="'visited'">
65+ <ui-field label="Date de visite" controlId="visitedOn" [error]="errorOf('visitedOn')">
66+ <input
67+ id="visitedOn"
68+ type="date"
69+ formControlName="visitedOn"
70+ [attr.max]="maxDate"
71+ [attr.aria-describedby]="errorOf('visitedOn') ? 'visitedOn-error' : null"
72+ />
73+ </ui-field>
74+
75+ <ui-field label="Note (facultative)" controlId="rating">
76+ <select id="rating" formControlName="rating">
77+ <option [ngValue]="null">Pas de note</option>
78+ <option *ngFor="let rating of ratings" [ngValue]="rating">{{ rating }} sur 5</option>
79+ </select>
80+ </ui-field>
10481 </div>
105- </div>
106- <div class="field" *ngSwitchDefault>
107- <label for="priority">Priorité</label>
108- <select id="priority" formControlName="priority">
109- <option *ngFor="let priority of priorities" [ngValue]="priority">{{ priorityLabels[priority] }}</option>
110- </select>
111- </div>
112- </ng-container>
82+ <ui-field *ngSwitchDefault label="Priorité" controlId="priority">
83+ <select id="priority" formControlName="priority">
84+ <option *ngFor="let priority of priorities" [ngValue]="priority">{{ priorityLabels[priority] }}</option>
85+ </select>
86+ </ui-field>
87+ </ng-container>
11388
114- <div class="field">
115- <label for="note">Note personnelle (facultative)</label>
116- <textarea
117- id="note"
118- rows="3"
119- formControlName="note"
120- [attr.aria-describedby]="errorOf('note') ? 'note-error' : null"
121- ></textarea>
122- <p *ngIf="errorOf('note') as message" class="field__error" id="note-error" role="alert">{{ message }}</p>
123- </div>
89+ <ui-field label="Note personnelle (facultative)" controlId="note" [error]="errorOf('note')">
90+ <textarea
91+ id="note"
92+ rows="3"
93+ formControlName="note"
94+ [attr.aria-describedby]="errorOf('note') ? 'note-error' : null"
95+ ></textarea>
96+ </ui-field>
12497
125- <div class="actions">
126- <button type="submit" class="btn btn--primary">Enregistrer</button>
127- <a class="btn" routerLink="/resume">Annuler</a>
128- </div>
129- </form>
98+ <div class="actions">
99+ <button type="submit" ui-button variant="primary">Enregistrer</button>
100+ <a ui-button routerLink="/resume">Annuler</a>
101+ </div>
102+ </form>
103+ </ui-card>
130104
131- <section class="card sticky" aria-labelledby="picker-title">
105+ <ui-card class="sticky" aria-labelledby="picker-title">
132106 <h2 id="picker-title">Placer le lieu sur la carte</h2>
133107 <div class="map-slot">
134108 <app-travel-map
@@ -139,6 +113,6 @@
139113 (locationPicked)="onPick($event)"
140114 ></app-travel-map>
141115 </div>
142- </section>
116+ </ui-card>
143117 </div>
144118 </section>
@@ -1,134 +1,108 @@
1 <section class="page">1 <section class="page">
2- <header class="page__header">2+ <ui-page-header eyebrow="Nouveau lieu" heading="Ajouter un lieu à votre carnet"></ui-page-header>
3- <p class="eyebrow">Nouveau lieu</p>
4- <h1>Ajouter un lieu à votre carnet</h1>
5- </header>
6 3
7 <div class="layout">4 <div class="layout">
8- <form class="card form" [formGroup]="form" (ngSubmit)="submit()" novalidate>5+ <ui-card>
9- <fieldset class="segmented">6+ <form class="form" [formGroup]="form" (ngSubmit)="submit()" novalidate>
10- <legend>Ce lieu est…</legend>7+ <ui-segmented legend="Ce lieu est…" formControlName="status" [options]="statusOptions"></ui-segmented>
11- <label>
12- <input type="radio" formControlName="status" value="visited" />
13- <span>Déjà visité</span>
14- </label>
15- <label>
16- <input type="radio" formControlName="status" value="wishlist" />
17- <span>Sur ma liste de souhaits</span>
18- </label>
19- </fieldset>
20 8
21- <div class="field">9+ <ui-field label="Nom du lieu" controlId="name" [error]="errorOf('name')">
22- <label for="name">Nom du lieu</label>
23- <input
24- id="name"
25- type="text"
26- formControlName="name"
27- autocomplete="off"
28- placeholder="Annecy, Col de l’Iseran…"
29- [attr.aria-describedby]="errorOf('name') ? 'name-error' : null"
30- />
31- <p *ngIf="errorOf('name') as message" class="field__error" id="name-error" role="alert">{{ message }}</p>
32- </div>
33-
34- <div class="row">
35- <div class="field">
36- <label for="country">Pays</label>
37 <input10 <input
38- id="country"11+ id="name"
39 type="text"12 type="text"
40- formControlName="country"13+ formControlName="name"
41- autocomplete="country-name"14+ autocomplete="off"
42- [attr.aria-describedby]="errorOf('country') ? 'country-error' : null"15+ placeholder="Annecy, Col de l’Iseran…"
16+ [attr.aria-describedby]="errorOf('name') ? 'name-error' : null"
43 />17 />
44- <p *ngIf="errorOf('country') as message" class="field__error" id="country-error" role="alert">{{ message }}</p>18+ </ui-field>
45- </div>
46 19
47- <div class="field">20+ <div class="row">
48- <label for="category">Type de lieu</label>21+ <ui-field label="Pays" controlId="country" [error]="errorOf('country')">
49- <select id="category" formControlName="category">22+ <input
50- <option *ngFor="let category of categories" [ngValue]="category">{{ categoryLabels[category] }}</option>23+ id="country"
51- </select>24+ type="text"
52- </div>25+ formControlName="country"
53- </div>26+ autocomplete="country-name"
27+ [attr.aria-describedby]="errorOf('country') ? 'country-error' : null"
28+ />
29+ </ui-field>
54 30
55- <div class="row">31+ <ui-field label="Type de lieu" controlId="category">
56- <div class="field">32+ <select id="category" formControlName="category">
57- <label for="lat">Latitude</label>33+ <option *ngFor="let category of categories" [ngValue]="category">{{ categoryLabels[category] }}</option>
58- <input34+ </select>
59- id="lat"35+ </ui-field>
60- type="number"
61- step="any"
62- inputmode="decimal"
63- formControlName="lat"
64- [attr.aria-describedby]="errorOf('lat') ? 'lat-error' : null"
65- />
66- <p *ngIf="errorOf('lat') as message" class="field__error" id="lat-error" role="alert">{{ message }}</p>
67 </div>36 </div>
68 37
69- <div class="field">38+ <div class="row">
70- <label for="lng">Longitude</label>39+ <ui-field label="Latitude" controlId="lat" [error]="errorOf('lat')">
71- <input40+ <input
72- id="lng"41+ id="lat"
73- type="number"42+ type="number"
74- step="any"43+ step="any"
75- inputmode="decimal"44+ inputmode="decimal"
76- formControlName="lng"45+ formControlName="lat"
77- [attr.aria-describedby]="errorOf('lng') ? 'lng-error' : null"46+ [attr.aria-describedby]="errorOf('lat') ? 'lat-error' : null"
78- />47+ />
79- <p *ngIf="errorOf('lng') as message" class="field__error" id="lng-error" role="alert">{{ message }}</p>48+ </ui-field>
80- </div>
81- </div>
82- <p class="hint">Astuce : cliquez sur la carte pour remplir la latitude et la longitude.</p>
83 49
84- <ng-container [ngSwitch]="status">50+ <ui-field label="Longitude" controlId="lng" [error]="errorOf('lng')">
85- <div class="row" *ngSwitchCase="'visited'">
86- <div class="field">
87- <label for="visitedOn">Date de visite</label>
88 <input51 <input
89- id="visitedOn"52+ id="lng"
90- type="date"53+ type="number"
91- formControlName="visitedOn"54+ step="any"
92- [attr.max]="maxDate"55+ inputmode="decimal"
93- [attr.aria-describedby]="errorOf('visitedOn') ? 'visitedOn-error' : null"56+ formControlName="lng"
57+ [attr.aria-describedby]="errorOf('lng') ? 'lng-error' : null"
94 />58 />
95- <p *ngIf="errorOf('visitedOn') as message" class="field__error" id="visitedOn-error" role="alert">{{ message }}</p>59+ </ui-field>
96- </div>60+ </div>
61+ <p class="hint">Astuce : cliquez sur la carte pour remplir la latitude et la longitude.</p>
97 62
98- <div class="field">63+ <ng-container [ngSwitch]="status">
99- <label for="rating">Note (facultative)</label>64+ <div class="row" *ngSwitchCase="'visited'">
100- <select id="rating" formControlName="rating">65+ <ui-field label="Date de visite" controlId="visitedOn" [error]="errorOf('visitedOn')">
101- <option [ngValue]="null">Pas de note</option>66+ <input
102- <option *ngFor="let rating of ratings" [ngValue]="rating">{{ rating }} sur 5</option>67+ id="visitedOn"
103- </select>68+ type="date"
69+ formControlName="visitedOn"
70+ [attr.max]="maxDate"
71+ [attr.aria-describedby]="errorOf('visitedOn') ? 'visitedOn-error' : null"
72+ />
73+ </ui-field>
74+
75+ <ui-field label="Note (facultative)" controlId="rating">
76+ <select id="rating" formControlName="rating">
77+ <option [ngValue]="null">Pas de note</option>
78+ <option *ngFor="let rating of ratings" [ngValue]="rating">{{ rating }} sur 5</option>
79+ </select>
80+ </ui-field>
104 </div>81 </div>
105- </div>82+ <ui-field *ngSwitchDefault label="Priorité" controlId="priority">
106- <div class="field" *ngSwitchDefault>83+ <select id="priority" formControlName="priority">
107- <label for="priority">Priorité</label>84+ <option *ngFor="let priority of priorities" [ngValue]="priority">{{ priorityLabels[priority] }}</option>
108- <select id="priority" formControlName="priority">85+ </select>
109- <option *ngFor="let priority of priorities" [ngValue]="priority">{{ priorityLabels[priority] }}</option>86+ </ui-field>
110- </select>87+ </ng-container>
111- </div>
112- </ng-container>
113 88
114- <div class="field">89+ <ui-field label="Note personnelle (facultative)" controlId="note" [error]="errorOf('note')">
115- <label for="note">Note personnelle (facultative)</label>90+ <textarea
116- <textarea91+ id="note"
117- id="note"92+ rows="3"
118- rows="3"93+ formControlName="note"
119- formControlName="note"94+ [attr.aria-describedby]="errorOf('note') ? 'note-error' : null"
120- [attr.aria-describedby]="errorOf('note') ? 'note-error' : null"95+ ></textarea>
121- ></textarea>96+ </ui-field>
122- <p *ngIf="errorOf('note') as message" class="field__error" id="note-error" role="alert">{{ message }}</p>
123- </div>
124 97
125- <div class="actions">98+ <div class="actions">
126- <button type="submit" class="btn btn--primary">Enregistrer</button>99+ <button type="submit" ui-button variant="primary">Enregistrer</button>
127- <a class="btn" routerLink="/resume">Annuler</a>100+ <a ui-button routerLink="/resume">Annuler</a>
128- </div>101+ </div>
129- </form>102+ </form>
103+ </ui-card>
130 104
131- <section class="card sticky" aria-labelledby="picker-title">105+ <ui-card class="sticky" aria-labelledby="picker-title">
132 <h2 id="picker-title">Placer le lieu sur la carte</h2>106 <h2 id="picker-title">Placer le lieu sur la carte</h2>
133 <div class="map-slot">107 <div class="map-slot">
134 <app-travel-map108 <app-travel-map
@@ -139,6 +113,6 @@
139 (locationPicked)="onPick($event)"113 (locationPicked)="onPick($event)"
140 ></app-travel-map>114 ></app-travel-map>
141 </div>115 </div>
142- </section>116+ </ui-card>
143 </div>117 </div>
144 </section>118 </section>
modified src/app/travel/pages/place-form-page/place-form-page.component.spec.ts +1 -1
@@ -68,7 +68,7 @@ describe('PlaceFormPageComponent', () => {
6868
6969 expect(store.places().length).toBe(0);
7070 expect(navigate).not.toHaveBeenCalled();
71- expect(root.querySelectorAll('.field__error').length).toBeGreaterThanOrEqual(4);
71+ expect(root.querySelectorAll('.ui-field__error').length).toBeGreaterThanOrEqual(4);
7272 });
7373
7474 it('refuse une latitude hors limites', () => {
@@ -68,7 +68,7 @@ describe('PlaceFormPageComponent', () => {
68 68
69 expect(store.places().length).toBe(0);69 expect(store.places().length).toBe(0);
70 expect(navigate).not.toHaveBeenCalled();70 expect(navigate).not.toHaveBeenCalled();
71- expect(root.querySelectorAll('.field__error').length).toBeGreaterThanOrEqual(4);71+ expect(root.querySelectorAll('.ui-field__error').length).toBeGreaterThanOrEqual(4);
72 });72 });
73 73
74 it('refuse une latitude hors limites', () => {74 it('refuse une latitude hors limites', () => {
modified src/app/travel/pages/place-form-page/place-form-page.component.ts +23 -1
@@ -2,6 +2,14 @@ import { CommonModule } from '@angular/common';
22 import { Component, OnInit } from '@angular/core';
33 import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
44 import { Router, RouterLink } from '@angular/router';
5+import {
6+ UiButtonComponent,
7+ UiCardComponent,
8+ UiFieldComponent,
9+ UiOption,
10+ UiPageHeaderComponent,
11+ UiSegmentedComponent,
12+} from 'ui-kit';
513 import { TravelMapComponent } from '../../components/travel-map/travel-map.component';
614 import {
715 CATEGORIES,
@@ -22,7 +30,17 @@ type TextField = 'name' | 'country' | 'lat' | 'lng' | 'visitedOn' | 'note';
2230 @Component({
2331 selector: 'app-place-form-page',
2432 standalone: true,
25- imports: [CommonModule, ReactiveFormsModule, RouterLink, TravelMapComponent],
33+ imports: [
34+ CommonModule,
35+ ReactiveFormsModule,
36+ RouterLink,
37+ TravelMapComponent,
38+ UiButtonComponent,
39+ UiCardComponent,
40+ UiFieldComponent,
41+ UiPageHeaderComponent,
42+ UiSegmentedComponent,
43+ ],
2644 templateUrl: './place-form-page.component.html',
2745 })
2846 export class PlaceFormPageComponent implements OnInit {
@@ -32,6 +50,10 @@ export class PlaceFormPageComponent implements OnInit {
3250 priorityLabels = PRIORITY_LABELS;
3351 ratings = [1, 2, 3, 4, 5];
3452 maxDate = todayIso();
53+ statusOptions: UiOption<PlaceStatus>[] = [
54+ { value: 'visited', label: 'Déjà visité' },
55+ { value: 'wishlist', label: 'Sur ma liste de souhaits' },
56+ ];
3557
3658 status: PlaceStatus = 'visited';
3759 /** Point à afficher sur la carte, tant que la latitude et la longitude saisies sont valides. */
@@ -2,6 +2,14 @@ import { CommonModule } from '@angular/common';
2 import { Component, OnInit } from '@angular/core';2 import { Component, OnInit } from '@angular/core';
3 import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';3 import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
4 import { Router, RouterLink } from '@angular/router';4 import { Router, RouterLink } from '@angular/router';
5+import {
6+ UiButtonComponent,
7+ UiCardComponent,
8+ UiFieldComponent,
9+ UiOption,
10+ UiPageHeaderComponent,
11+ UiSegmentedComponent,
12+} from 'ui-kit';
5 import { TravelMapComponent } from '../../components/travel-map/travel-map.component';13 import { TravelMapComponent } from '../../components/travel-map/travel-map.component';
6 import {14 import {
7 CATEGORIES,15 CATEGORIES,
@@ -22,7 +30,17 @@ type TextField = 'name' | 'country' | 'lat' | 'lng' | 'visitedOn' | 'note';
22 @Component({30 @Component({
23 selector: 'app-place-form-page',31 selector: 'app-place-form-page',
24 standalone: true,32 standalone: true,
25- imports: [CommonModule, ReactiveFormsModule, RouterLink, TravelMapComponent],33+ imports: [
34+ CommonModule,
35+ ReactiveFormsModule,
36+ RouterLink,
37+ TravelMapComponent,
38+ UiButtonComponent,
39+ UiCardComponent,
40+ UiFieldComponent,
41+ UiPageHeaderComponent,
42+ UiSegmentedComponent,
43+ ],
26 templateUrl: './place-form-page.component.html',44 templateUrl: './place-form-page.component.html',
27 })45 })
28 export class PlaceFormPageComponent implements OnInit {46 export class PlaceFormPageComponent implements OnInit {
@@ -32,6 +50,10 @@ export class PlaceFormPageComponent implements OnInit {
32 priorityLabels = PRIORITY_LABELS;50 priorityLabels = PRIORITY_LABELS;
33 ratings = [1, 2, 3, 4, 5];51 ratings = [1, 2, 3, 4, 5];
34 maxDate = todayIso();52 maxDate = todayIso();
53+ statusOptions: UiOption<PlaceStatus>[] = [
54+ { value: 'visited', label: 'Déjà visité' },
55+ { value: 'wishlist', label: 'Sur ma liste de souhaits' },
56+ ];
35 57
36 status: PlaceStatus = 'visited';58 status: PlaceStatus = 'visited';
37 /** Point à afficher sur la carte, tant que la latitude et la longitude saisies sont valides. */59 /** Point à afficher sur la carte, tant que la latitude et la longitude saisies sont valides. */
modified src/app/travel/pages/summary-page/summary-page.component.html +18 -28
@@ -1,43 +1,33 @@
11 <section class="page">
2- <header class="page__header">
3- <p class="eyebrow">Carnet de voyage</p>
4- <h1>Votre monde en un coup d’œil</h1>
5- </header>
2+ <ui-page-header eyebrow="Carnet de voyage" heading="Votre monde en un coup d’œil"></ui-page-header>
63
74 <ul class="stat-grid" aria-label="Statistiques de voyage">
8- <li class="card stat">
9- <span class="stat__value">{{ store.stats().visitedCount }}</span>
10- <span class="stat__label">{{ store.stats().visitedCount > 1 ? 'lieux visités' : 'lieu visité' }}</span>
5+ <li>
6+ <ui-stat [value]="store.stats().visitedCount" [label]="store.stats().visitedCount > 1 ? 'lieux visités' : 'lieu visité'"></ui-stat>
117 </li>
12- <li class="card stat">
13- <span class="stat__value">{{ store.stats().countryCount }}</span>
14- <span class="stat__label">pays</span>
8+ <li>
9+ <ui-stat [value]="store.stats().countryCount" label="pays"></ui-stat>
1510 </li>
16- <li class="card stat">
17- <span class="stat__value">{{ store.stats().averageRating ?? '—' }}</span>
18- <span class="stat__label">note moyenne</span>
11+ <li>
12+ <ui-stat [value]="store.stats().averageRating ?? '—'" label="note moyenne"></ui-stat>
1913 </li>
20- <li class="card stat">
21- <span class="stat__value">{{ store.stats().wishlistCount }}</span>
22- <span class="stat__label">à découvrir</span>
14+ <li>
15+ <ui-stat [value]="store.stats().wishlistCount" label="à découvrir"></ui-stat>
2316 </li>
2417 </ul>
2518
2619 <div class="layout layout--map-first">
27- <section class="card" aria-labelledby="map-title">
28- <div class="card__head">
20+ <ui-card aria-labelledby="map-title">
21+ <div class="card-head">
Info: a non-blocking note Athena Oct 11, 2026 Permalink to @athena's comment

The old legend list had aria-label="Légende" on the . Unless UiLegendComponent sets an accessible label, the legend loses that grouping announcement. Worth double-checking the ui-kit component.

2922 <h2 id="map-title">Carte</h2>
30- <ul class="legend" aria-label="Légende">
31- <li><span class="legend__dot legend__dot--visited" aria-hidden="true"></span>Visité</li>
32- <li><span class="legend__dot legend__dot--wishlist" aria-hidden="true"></span>À visiter</li>
33- </ul>
23+ <ui-legend [items]="legend"></ui-legend>
3424 </div>
3525 <div class="map-slot">
3626 <app-travel-map [places]="store.places()" label="Carte de vos lieux visités et de vos souhaits"></app-travel-map>
3727 </div>
38- </section>
28+ </ui-card>
3929
40- <section class="card" aria-labelledby="recent-title">
30+ <ui-card aria-labelledby="recent-title">
4131 <h2 id="recent-title">Derniers voyages</h2>
4232
4333 <ol class="timeline" *ngIf="recent().length > 0; else noTrip">
@@ -48,18 +38,18 @@
4838 </div>
4939 <div class="timeline__meta">
5040 <time [attr.datetime]="place.visitedOn">{{ place.visitedOn | date: 'longDate' }}</time>
51- <app-rating [value]="place.rating"></app-rating>
41+ <ui-rating [value]="place.rating"></ui-rating>
5242 </div>
5343 <p class="timeline__note" *ngIf="place.note">{{ place.note }}</p>
5444 </li>
5545 </ol>
5646
5747 <ng-template #noTrip>
58- <p class="empty">
48+ <ui-empty-state>
5949 Aucun lieu visité pour l’instant.
6050 <a routerLink="/lieux/nouveau">Ajoutez votre premier voyage</a>.
61- </p>
51+ </ui-empty-state>
6252 </ng-template>
63- </section>
53+ </ui-card>
6454 </div>
6555 </section>
@@ -1,43 +1,33 @@
1 <section class="page">1 <section class="page">
2- <header class="page__header">2+ <ui-page-header eyebrow="Carnet de voyage" heading="Votre monde en un coup d’œil"></ui-page-header>
3- <p class="eyebrow">Carnet de voyage</p>
4- <h1>Votre monde en un coup d’œil</h1>
5- </header>
6 3
7 <ul class="stat-grid" aria-label="Statistiques de voyage">4 <ul class="stat-grid" aria-label="Statistiques de voyage">
8- <li class="card stat">5+ <li>
9- <span class="stat__value">{{ store.stats().visitedCount }}</span>6+ <ui-stat [value]="store.stats().visitedCount" [label]="store.stats().visitedCount > 1 ? 'lieux visités' : 'lieu visité'"></ui-stat>
10- <span class="stat__label">{{ store.stats().visitedCount > 1 ? 'lieux visités' : 'lieu visité' }}</span>
11 </li>7 </li>
12- <li class="card stat">8+ <li>
13- <span class="stat__value">{{ store.stats().countryCount }}</span>9+ <ui-stat [value]="store.stats().countryCount" label="pays"></ui-stat>
14- <span class="stat__label">pays</span>
15 </li>10 </li>
16- <li class="card stat">11+ <li>
17- <span class="stat__value">{{ store.stats().averageRating ?? '—' }}</span>12+ <ui-stat [value]="store.stats().averageRating ?? '—'" label="note moyenne"></ui-stat>
18- <span class="stat__label">note moyenne</span>
19 </li>13 </li>
20- <li class="card stat">14+ <li>
21- <span class="stat__value">{{ store.stats().wishlistCount }}</span>15+ <ui-stat [value]="store.stats().wishlistCount" label="à découvrir"></ui-stat>
22- <span class="stat__label">à découvrir</span>
23 </li>16 </li>
24 </ul>17 </ul>
25 18
26 <div class="layout layout--map-first">19 <div class="layout layout--map-first">
27- <section class="card" aria-labelledby="map-title">20+ <ui-card aria-labelledby="map-title">
28- <div class="card__head">21+ <div class="card-head">
29 <h2 id="map-title">Carte</h2>22 <h2 id="map-title">Carte</h2>
30- <ul class="legend" aria-label="Légende">23+ <ui-legend [items]="legend"></ui-legend>
31- <li><span class="legend__dot legend__dot--visited" aria-hidden="true"></span>Visité</li>
32- <li><span class="legend__dot legend__dot--wishlist" aria-hidden="true"></span>À visiter</li>
33- </ul>
34 </div>24 </div>
35 <div class="map-slot">25 <div class="map-slot">
36 <app-travel-map [places]="store.places()" label="Carte de vos lieux visités et de vos souhaits"></app-travel-map>26 <app-travel-map [places]="store.places()" label="Carte de vos lieux visités et de vos souhaits"></app-travel-map>
37 </div>27 </div>
38- </section>28+ </ui-card>
39 29
40- <section class="card" aria-labelledby="recent-title">30+ <ui-card aria-labelledby="recent-title">
41 <h2 id="recent-title">Derniers voyages</h2>31 <h2 id="recent-title">Derniers voyages</h2>
42 32
43 <ol class="timeline" *ngIf="recent().length > 0; else noTrip">33 <ol class="timeline" *ngIf="recent().length > 0; else noTrip">
@@ -48,18 +38,18 @@
48 </div>38 </div>
49 <div class="timeline__meta">39 <div class="timeline__meta">
50 <time [attr.datetime]="place.visitedOn">{{ place.visitedOn | date: 'longDate' }}</time>40 <time [attr.datetime]="place.visitedOn">{{ place.visitedOn | date: 'longDate' }}</time>
51- <app-rating [value]="place.rating"></app-rating>41+ <ui-rating [value]="place.rating"></ui-rating>
52 </div>42 </div>
53 <p class="timeline__note" *ngIf="place.note">{{ place.note }}</p>43 <p class="timeline__note" *ngIf="place.note">{{ place.note }}</p>
54 </li>44 </li>
55 </ol>45 </ol>
56 46
57 <ng-template #noTrip>47 <ng-template #noTrip>
58- <p class="empty">48+ <ui-empty-state>
59 Aucun lieu visité pour l’instant.49 Aucun lieu visité pour l’instant.
60 <a routerLink="/lieux/nouveau">Ajoutez votre premier voyage</a>.50 <a routerLink="/lieux/nouveau">Ajoutez votre premier voyage</a>.
61- </p>51+ </ui-empty-state>
62 </ng-template>52 </ng-template>
63- </section>53+ </ui-card>
64 </div>54 </div>
65 </section>55 </section>
modified src/app/travel/pages/summary-page/summary-page.component.spec.ts +2 -2
@@ -21,7 +21,7 @@ describe('SummaryPageComponent', () => {
2121
2222 it('affiche les statistiques, la carte et les derniers voyages', async () => {
2323 const root = await render();
24- const values = Array.from(root.querySelectorAll('.stat__value')).map((el) => el.textContent?.trim());
24+ const values = Array.from(root.querySelectorAll('ui-stat .value')).map((el) => el.textContent?.trim());
Info: a non-blocking note Athena Oct 11, 2026 Permalink to @athena's comment

The selector 'ui-stat .value' now depends on an internal class of the ui-kit component. If ui-kit ever renames that class, this test breaks even though the component contract ([value]/[label] inputs) is unchanged. Querying 'ui-stat' elements and reading their text, or exposing a stable hook (e.g. data-testid or a host class), would be less brittle.

2525
2626 expect(values).toEqual(['5', '5', '4.6', '5']);
2727 expect(root.querySelector('app-travel-map')).not.toBeNull();
@@ -32,7 +32,7 @@ describe('SummaryPageComponent', () => {
3232 it('invite à ajouter un premier voyage quand rien n’est enregistré', async () => {
3333 const root = await render('[]');
3434
35- expect(root.querySelector('.empty a')?.getAttribute('href')).toBe('/lieux/nouveau');
35+ expect(root.querySelector('ui-empty-state a')?.getAttribute('href')).toBe('/lieux/nouveau');
3636 expect(root.querySelector('.timeline')).toBeNull();
3737 });
3838 });
@@ -21,7 +21,7 @@ describe('SummaryPageComponent', () => {
21 21
22 it('affiche les statistiques, la carte et les derniers voyages', async () => {22 it('affiche les statistiques, la carte et les derniers voyages', async () => {
23 const root = await render();23 const root = await render();
24- const values = Array.from(root.querySelectorAll('.stat__value')).map((el) => el.textContent?.trim());24+ const values = Array.from(root.querySelectorAll('ui-stat .value')).map((el) => el.textContent?.trim());
25 25
26 expect(values).toEqual(['5', '5', '4.6', '5']);26 expect(values).toEqual(['5', '5', '4.6', '5']);
27 expect(root.querySelector('app-travel-map')).not.toBeNull();27 expect(root.querySelector('app-travel-map')).not.toBeNull();
@@ -32,7 +32,7 @@ describe('SummaryPageComponent', () => {
32 it('invite à ajouter un premier voyage quand rien n’est enregistré', async () => {32 it('invite à ajouter un premier voyage quand rien n’est enregistré', async () => {
33 const root = await render('[]');33 const root = await render('[]');
34 34
35- expect(root.querySelector('.empty a')?.getAttribute('href')).toBe('/lieux/nouveau');35+ expect(root.querySelector('ui-empty-state a')?.getAttribute('href')).toBe('/lieux/nouveau');
36 expect(root.querySelector('.timeline')).toBeNull();36 expect(root.querySelector('.timeline')).toBeNull();
37 });37 });
38 });38 });
modified src/app/travel/pages/summary-page/summary-page.component.ts +24 -2
Large diff collapsed to keep the page fast — load it when you need it.
modified src/app/travel/pages/wishlist-page/wishlist-page.component.html +16 -18
Large diff collapsed to keep the page fast — load it when you need it.
modified src/app/travel/pages/wishlist-page/wishlist-page.component.spec.ts +2 -2
Large diff collapsed to keep the page fast — load it when you need it.
modified src/app/travel/pages/wishlist-page/wishlist-page.component.ts +19 -1
Large diff collapsed to keep the page fast — load it when you need it.
modified src/styles.css +14 -229
Large diff collapsed to keep the page fast — load it when you need it.