feat: Utilisation de la bibliotheque de composantsUnverified
2bde847 parent: cbf84e0 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 | ||
| 25 | 25 | ## Further help |
| 26 | 26 | |
| 27 | 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`. | |
| @@ -25,3 +25,7 @@ Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To u | |||
| 25 | ## Further help | 25 | ## 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 @@ | ||
| 35 | 35 | "scripts": [], |
| 36 | 36 | "allowedCommonJsDependencies": [ |
| 37 | 37 | "leaflet" |
| 38 | - ] | |
| 38 | + ], | |
| 39 | + "preserveSymlinks": true | |
| 39 | 40 | }, |
| 40 | 41 | "configurations": { |
| 41 | 42 | "production": { |
| @@ -93,7 +94,8 @@ | ||
| 93 | 94 | "styles": [ |
| 94 | 95 | "src/styles.css" |
| 95 | 96 | ], |
| 96 | - "scripts": [] | |
| 97 | + "scripts": [], | |
| 98 | + "preserveSymlinks": true | |
| 97 | 99 | } |
| 98 | 100 | } |
| 99 | 101 | } |
| @@ -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 | } |
modified
package.json +2 -0 | @@ -3,6 +3,7 @@ | ||
| 3 | 3 | "version": "0.0.0", |
| 4 | 4 | "scripts": { |
| 5 | 5 | "ng": "ng", |
| 6 | + "ui-kit:build": "pnpm --dir ../ui-kit build", | |
| 6 | 7 | "start": "ng serve", |
| 7 | 8 | "build": "ng build", |
| 8 | 9 | "watch": "ng build --watch --configuration development", |
| @@ -21,6 +22,7 @@ | ||
| 21 | 22 | "leaflet": "^1.9.4", |
| 22 | 23 | "rxjs": "~7.8.0", |
| 23 | 24 | "tslib": "^2.3.0", |
| 25 | + "ui-kit": "link:../ui-kit/dist/ui-kit", | |
| 24 | 26 | "zone.js": "~0.14.10" |
| 25 | 27 | }, |
| 26 | 28 | "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: | ||
| 41 | 41 | tslib: |
| 42 | 42 | specifier: ^2.3.0 |
| 43 | 43 | version: 2.8.1 |
| 44 | + ui-kit: | |
| 45 | + specifier: link:../ui-kit/dist/ui-kit | |
| 46 | + version: link:../ui-kit/dist/ui-kit | |
| 44 | 47 | zone.js: |
| 45 | 48 | specifier: ~0.14.10 |
| 46 | 49 | version: 0.14.10 |
| @@ -41,6 +41,9 @@ importers: | |||
| 41 | tslib: | 41 | tslib: |
| 42 | specifier: ^2.3.0 | 42 | specifier: ^2.3.0 |
| 43 | version: 2.8.1 | 43 | 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.10 | 48 | specifier: ~0.14.10 |
| 46 | version: 0.14.10 | 49 | version: 0.14.10 |
modified
src/app/app.component.css +1 -1 | @@ -48,7 +48,7 @@ nav a:hover { | ||
| 48 | 48 | |
| 49 | 49 | nav a.is-active { |
| 50 | 50 | background: var(--visited); |
| 51 | - color: var(--on-accent); | |
| 51 | + color: var(--ui-on-accent); | |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | .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', () => { | ||
| 39 | 39 | fixture.componentRef.setInput('places', places); |
| 40 | 40 | const root = render(); |
| 41 | 41 | |
| 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); | |
| 45 | 45 | }); |
| 46 | 46 | |
| 47 | 47 | it('met à jour les repères quand la liste change', () => { |
| @@ -51,12 +51,12 @@ describe('TravelMapComponent', () => { | ||
| 51 | 51 | fixture.componentRef.setInput('places', places.slice(0, 1)); |
| 52 | 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 | 57 | it('affiche le point à placer', () => { |
| 58 | 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 | 62 | 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'; | |
| 15 | 3 | import { CATEGORY_LABELS, LatLng, Place } from '../../models/place.model'; |
| 16 | 4 | import { formatIsoDate } from '../../utils/date'; |
| 17 | 5 | |
| 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. */ | |
| 20 | 7 | @Component({ |
| 21 | 8 | selector: 'app-travel-map', |
| 22 | 9 | 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" | |
| 15 | + [selected]="picked" | |
| 16 | + [label]="label" | |
| 17 | + (pointSelected)="locationPicked.emit($event)" | |
| 18 | + ></ui-map> | |
| 19 | + `, | |
| 26 | 20 | styles: ` |
| 27 | - app-travel-map { | |
| 21 | + :host { | |
| 28 | 22 | display: block; |
| 29 | 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 | 28 | @Input() places: readonly Place[] = []; |
| 59 | 29 | /** Active la sélection d'un point par clic sur la carte. */ |
| 60 | 30 | @Input() pickable = false; |
| @@ -64,108 +34,23 @@ export class TravelMapComponent implements OnChanges, AfterViewInit, OnDestroy { | ||
| 64 | 34 | |
| 65 | 35 | @Output() locationPicked = new EventEmitter<LatLng>(); |
| 66 | 36 | |
| 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[] = []; | |
| 72 | 38 | |
| 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); | |
| 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: '© <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. */ | |
| 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 = | |
| 163 | 46 | place.status === 'visited' && place.visitedOn ? `Visité le ${formatIsoDate(place.visitedOn)}` : 'À visiter'; |
| 164 | 47 | |
| 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, | |
| 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 | + }; | |
| 171 | 56 | } |
| @@ -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: '© <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 @@ | ||
| 1 | 1 | <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> | |
| 6 | 3 | |
| 7 | 4 | <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> | |
| 20 | 8 | |
| 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')"> | |
| 37 | 10 | <input |
| 38 | - id="country" | |
| 11 | + id="name" | |
| 39 | 12 | 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" | |
| 43 | 17 | /> |
| 44 | - <p *ngIf="errorOf('country') as message" class="field__error" id="country-error" role="alert">{{ message }}</p> | |
| 45 | - </div> | |
| 18 | + </ui-field> | |
| 46 | 19 | |
| 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> | |
| 54 | 30 | |
| 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> | |
| 67 | 36 | </div> |
| 68 | 37 | |
| 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> | |
| 83 | 49 | |
| 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')"> | |
| 88 | 51 | <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" | |
| 94 | 58 | /> |
| 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> | |
| 97 | 62 | |
| 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> | |
| 104 | 81 | </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> | |
| 113 | 88 | |
| 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> | |
| 124 | 97 | |
| 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> | |
| 130 | 104 | |
| 131 | - <section class="card sticky" aria-labelledby="picker-title"> | |
| 105 | + <ui-card class="sticky" aria-labelledby="picker-title"> | |
| 132 | 106 | <h2 id="picker-title">Placer le lieu sur la carte</h2> |
| 133 | 107 | <div class="map-slot"> |
| 134 | 108 | <app-travel-map |
| @@ -139,6 +113,6 @@ | ||
| 139 | 113 | (locationPicked)="onPick($event)" |
| 140 | 114 | ></app-travel-map> |
| 141 | 115 | </div> |
| 142 | - </section> | |
| 116 | + </ui-card> | |
| 143 | 117 | </div> |
| 144 | 118 | </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 | <input | 10 | <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 | - <input | 34 | + </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 | - <input | 40 | + <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 | <input | 51 | <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 | - <textarea | 91 | + 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-map | 108 | <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', () => { | ||
| 68 | 68 | |
| 69 | 69 | expect(store.places().length).toBe(0); |
| 70 | 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 | 74 | 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'; | ||
| 2 | 2 | import { Component, OnInit } from '@angular/core'; |
| 3 | 3 | import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; |
| 4 | 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 | 13 | import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; |
| 6 | 14 | import { |
| 7 | 15 | CATEGORIES, |
| @@ -22,7 +30,17 @@ type TextField = 'name' | 'country' | 'lat' | 'lng' | 'visitedOn' | 'note'; | ||
| 22 | 30 | @Component({ |
| 23 | 31 | selector: 'app-place-form-page', |
| 24 | 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 | 44 | templateUrl: './place-form-page.component.html', |
| 27 | 45 | }) |
| 28 | 46 | export class PlaceFormPageComponent implements OnInit { |
| @@ -32,6 +50,10 @@ export class PlaceFormPageComponent implements OnInit { | ||
| 32 | 50 | priorityLabels = PRIORITY_LABELS; |
| 33 | 51 | ratings = [1, 2, 3, 4, 5]; |
| 34 | 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 | 58 | status: PlaceStatus = 'visited'; |
| 37 | 59 | /** 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 @@ | ||
| 1 | 1 | <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> | |
| 6 | 3 | |
| 7 | 4 | <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> | |
| 11 | 7 | </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> | |
| 15 | 10 | </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> | |
| 19 | 13 | </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> | |
| 23 | 16 | </li> |
| 24 | 17 | </ul> |
| 25 | 18 | |
| 26 | 19 | <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"> | |
| 29 | 22 | <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> | |
| 34 | 24 | </div> |
| 35 | 25 | <div class="map-slot"> |
| 36 | 26 | <app-travel-map [places]="store.places()" label="Carte de vos lieux visités et de vos souhaits"></app-travel-map> |
| 37 | 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 | 31 | <h2 id="recent-title">Derniers voyages</h2> |
| 42 | 32 | |
| 43 | 33 | <ol class="timeline" *ngIf="recent().length > 0; else noTrip"> |
| @@ -48,18 +38,18 @@ | ||
| 48 | 38 | </div> |
| 49 | 39 | <div class="timeline__meta"> |
| 50 | 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 | 42 | </div> |
| 53 | 43 | <p class="timeline__note" *ngIf="place.note">{{ place.note }}</p> |
| 54 | 44 | </li> |
| 55 | 45 | </ol> |
| 56 | 46 | |
| 57 | 47 | <ng-template #noTrip> |
| 58 | - <p class="empty"> | |
| 48 | + <ui-empty-state> | |
| 59 | 49 | Aucun lieu visité pour l’instant. |
| 60 | 50 | <a routerLink="/lieux/nouveau">Ajoutez votre premier voyage</a>. |
| 61 | - </p> | |
| 51 | + </ui-empty-state> | |
| 62 | 52 | </ng-template> |
| 63 | - </section> | |
| 53 | + </ui-card> | |
| 64 | 54 | </div> |
| 65 | 55 | </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', () => { | ||
| 21 | 21 | |
| 22 | 22 | it('affiche les statistiques, la carte et les derniers voyages', async () => { |
| 23 | 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 | 26 | expect(values).toEqual(['5', '5', '4.6', '5']); |
| 27 | 27 | expect(root.querySelector('app-travel-map')).not.toBeNull(); |
| @@ -32,7 +32,7 @@ describe('SummaryPageComponent', () => { | ||
| 32 | 32 | it('invite à ajouter un premier voyage quand rien n’est enregistré', async () => { |
| 33 | 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 | 36 | expect(root.querySelector('.timeline')).toBeNull(); |
| 37 | 37 | }); |
| 38 | 38 | }); |
| @@ -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 | @@ -1,7 +1,15 @@ | ||
| 1 | 1 | import { CommonModule } from '@angular/common'; |
| 2 | 2 | import { Component, Signal, computed } from '@angular/core'; |
| 3 | 3 | import { RouterLink } from '@angular/router'; |
| 4 | -import { RatingComponent } from '../../components/rating/rating.component'; | |
| 4 | +import { | |
| 5 | + UiCardComponent, | |
| 6 | + UiEmptyStateComponent, | |
| 7 | + UiLegendComponent, | |
| 8 | + UiLegendItem, | |
| 9 | + UiPageHeaderComponent, | |
| 10 | + UiRatingComponent, | |
| 11 | + UiStatComponent, | |
| 12 | +} from 'ui-kit'; | |
| 5 | 13 | import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; |
| 6 | 14 | import { CATEGORY_LABELS, Place } from '../../models/place.model'; |
| 7 | 15 | import { TravelStore } from '../../services/travel-store.service'; |
| @@ -9,11 +17,25 @@ import { TravelStore } from '../../services/travel-store.service'; | ||
| 9 | 17 | @Component({ |
| 10 | 18 | selector: 'app-summary-page', |
| 11 | 19 | standalone: true, |
| 12 | - imports: [CommonModule, RouterLink, RatingComponent, TravelMapComponent], | |
| 20 | + imports: [ | |
| 21 | + CommonModule, | |
| 22 | + RouterLink, | |
| 23 | + TravelMapComponent, | |
| 24 | + UiCardComponent, | |
| 25 | + UiEmptyStateComponent, | |
| 26 | + UiLegendComponent, | |
| 27 | + UiPageHeaderComponent, | |
| 28 | + UiRatingComponent, | |
| 29 | + UiStatComponent, | |
| 30 | + ], | |
| 13 | 31 | templateUrl: './summary-page.component.html', |
| 14 | 32 | }) |
| 15 | 33 | export class SummaryPageComponent { |
| 16 | 34 | categoryLabels = CATEGORY_LABELS; |
| 35 | + legend: UiLegendItem[] = [ | |
| 36 | + { kind: 'primary', label: 'Visité' }, | |
| 37 | + { kind: 'secondary', label: 'À visiter' }, | |
| 38 | + ]; | |
| 17 | 39 | recent: Signal<Place[]>; |
| 18 | 40 | |
| 19 | 41 | constructor(public store: TravelStore) { |
| @@ -1,7 +1,15 @@ | |||
| 1 | import { CommonModule } from '@angular/common'; | 1 | import { CommonModule } from '@angular/common'; |
| 2 | import { Component, Signal, computed } from '@angular/core'; | 2 | import { Component, Signal, computed } from '@angular/core'; |
| 3 | import { RouterLink } from '@angular/router'; | 3 | import { RouterLink } from '@angular/router'; |
| 4 | -import { RatingComponent } from '../../components/rating/rating.component'; | 4 | +import { |
| 5 | + UiCardComponent, | ||
| 6 | + UiEmptyStateComponent, | ||
| 7 | + UiLegendComponent, | ||
| 8 | + UiLegendItem, | ||
| 9 | + UiPageHeaderComponent, | ||
| 10 | + UiRatingComponent, | ||
| 11 | + UiStatComponent, | ||
| 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 { CATEGORY_LABELS, Place } from '../../models/place.model'; | 14 | import { CATEGORY_LABELS, Place } from '../../models/place.model'; |
| 7 | import { TravelStore } from '../../services/travel-store.service'; | 15 | import { TravelStore } from '../../services/travel-store.service'; |
| @@ -9,11 +17,25 @@ import { TravelStore } from '../../services/travel-store.service'; | |||
| 9 | @Component({ | 17 | @Component({ |
| 10 | selector: 'app-summary-page', | 18 | selector: 'app-summary-page', |
| 11 | standalone: true, | 19 | standalone: true, |
| 12 | - imports: [CommonModule, RouterLink, RatingComponent, TravelMapComponent], | 20 | + imports: [ |
| 21 | + CommonModule, | ||
| 22 | + RouterLink, | ||
| 23 | + TravelMapComponent, | ||
| 24 | + UiCardComponent, | ||
| 25 | + UiEmptyStateComponent, | ||
| 26 | + UiLegendComponent, | ||
| 27 | + UiPageHeaderComponent, | ||
| 28 | + UiRatingComponent, | ||
| 29 | + UiStatComponent, | ||
| 30 | + ], | ||
| 13 | templateUrl: './summary-page.component.html', | 31 | templateUrl: './summary-page.component.html', |
| 14 | }) | 32 | }) |
| 15 | export class SummaryPageComponent { | 33 | export class SummaryPageComponent { |
| 16 | categoryLabels = CATEGORY_LABELS; | 34 | categoryLabels = CATEGORY_LABELS; |
| 35 | + legend: UiLegendItem[] = [ | ||
| 36 | + { kind: 'primary', label: 'Visité' }, | ||
| 37 | + { kind: 'secondary', label: 'À visiter' }, | ||
| 38 | + ]; | ||
| 17 | recent: Signal<Place[]>; | 39 | recent: Signal<Place[]>; |
| 18 | 40 | ||
| 19 | constructor(public store: TravelStore) { | 41 | constructor(public store: TravelStore) { |
modified
src/app/travel/pages/wishlist-page/wishlist-page.component.html +16 -18 | @@ -1,17 +1,14 @@ | ||
| 1 | 1 | <section class="page"> |
| 2 | - <header class="page__header"> | |
| 3 | - <p class="eyebrow">Liste de souhaits</p> | |
| 4 | - <h1>Là où vous voulez aller</h1> | |
| 5 | - </header> | |
| 2 | + <ui-page-header eyebrow="Liste de souhaits" heading="Là où vous voulez aller"></ui-page-header> | |
| 6 | 3 | |
| 7 | - <p class="notice" role="status" aria-live="polite"> | |
| 4 | + <ui-notice> | |
| 8 | 5 | <ng-container *ngIf="justVisited"> |
| 9 | 6 | {{ justVisited }} rejoint vos lieux visités. <a routerLink="/resume">Voir le résumé</a> |
| 10 | 7 | </ng-container> |
| 11 | - </p> | |
| 8 | + </ui-notice> | |
| 12 | 9 | |
| 13 | 10 | <div class="layout"> |
| 14 | - <section class="card" aria-labelledby="wishlist-title"> | |
| 11 | + <ui-card aria-labelledby="wishlist-title"> | |
| 15 | 12 | <h2 id="wishlist-title"> |
| 16 | 13 | {{ store.wishlist().length }} {{ store.wishlist().length > 1 ? 'destinations' : 'destination' }} |
| 17 | 14 | </h2> |
| @@ -20,9 +17,9 @@ | ||
| 20 | 17 | <li class="wish" *ngFor="let place of store.wishlist()"> |
| 21 | 18 | <div class="wish__head"> |
| 22 | 19 | <strong>{{ place.name }}</strong> |
| 23 | - <span class="badge" [ngClass]="'badge--' + (place.priority || 'medium')"> | |
| 20 | + <ui-badge [tone]="place.priority === 'high' ? 'warm' : 'accent'"> | |
| 24 | 21 | {{ priorityLabels[place.priority || 'medium'] }} |
| 25 | - </span> | |
| 22 | + </ui-badge> | |
| 26 | 23 | </div> |
| 27 | 24 | <span class="muted">{{ place.country }} · {{ categoryLabels[place.category] }}</span> |
| 28 | 25 | <p class="wish__note" *ngIf="place.note">{{ place.note }}</p> |
| @@ -30,7 +27,7 @@ | ||
| 30 | 27 | <div class="actions"> |
| 31 | 28 | <button |
| 32 | 29 | type="button" |
| 33 | - class="btn btn--primary btn--sm" | |
| 30 | + ui-button variant="primary" size="sm" | |
| 34 | 31 | [attr.aria-label]="'Marquer ' + place.name + ' comme visité'" |
| 35 | 32 | (click)="markVisited(place)" |
| 36 | 33 | > |
| @@ -40,18 +37,19 @@ | ||
| 40 | 37 | <ng-container *ngIf="pendingRemoval === place.id; else removeButton"> |
| 41 | 38 | <button |
| 42 | 39 | type="button" |
| 43 | - class="btn btn--danger btn--sm" | |
| 40 | + ui-button variant="danger" size="sm" | |
| 44 | 41 | [attr.aria-label]="'Confirmer la suppression de ' + place.name" |
| 45 | 42 | (click)="confirmRemoval(place)" |
| 46 | 43 | > |
| 47 | 44 | Confirmer |
| 48 | 45 | </button> |
| 49 | - <button type="button" class="btn btn--sm" (click)="cancelRemoval()">Annuler</button> | |
| 46 | + <button type="button" ui-button size="sm" (click)="cancelRemoval()">Annuler</button> | |
| 50 | 47 | </ng-container> |
| 51 | 48 | <ng-template #removeButton> |
| 52 | 49 | <button |
| 53 | 50 | type="button" |
| 54 | - class="btn btn--sm" | |
| 51 | + ui-button | |
| 52 | + size="sm" | |
| 55 | 53 | [attr.aria-label]="'Supprimer ' + place.name" |
| 56 | 54 | (click)="askRemoval(place)" |
| 57 | 55 | > |
| @@ -63,13 +61,13 @@ | ||
| 63 | 61 | </ul> |
| 64 | 62 | |
| 65 | 63 | <ng-template #emptyList> |
| 66 | - <p class="empty"> | |
| 64 | + <ui-empty-state> | |
| 67 | 65 | Votre liste est vide. <a routerLink="/lieux/nouveau">Ajoutez une destination</a> qui vous fait rêver. |
| 68 | - </p> | |
| 66 | + </ui-empty-state> | |
| 69 | 67 | </ng-template> |
| 70 | - </section> | |
| 68 | + </ui-card> | |
| 71 | 69 | |
| 72 | - <section class="card" aria-labelledby="wish-map-title"> | |
| 70 | + <ui-card aria-labelledby="wish-map-title"> | |
| 73 | 71 | <h2 id="wish-map-title">Sur la carte</h2> |
| 74 | 72 | <div class="map-slot"> |
| 75 | 73 | <app-travel-map |
| @@ -77,6 +75,6 @@ | ||
| 77 | 75 | label="Carte des destinations de votre liste de souhaits" |
| 78 | 76 | ></app-travel-map> |
| 79 | 77 | </div> |
| 80 | - </section> | |
| 78 | + </ui-card> | |
| 81 | 79 | </div> |
| 82 | 80 | </section> |
| @@ -1,17 +1,14 @@ | |||
| 1 | <section class="page"> | 1 | <section class="page"> |
| 2 | - <header class="page__header"> | 2 | + <ui-page-header eyebrow="Liste de souhaits" heading="Là où vous voulez aller"></ui-page-header> |
| 3 | - <p class="eyebrow">Liste de souhaits</p> | ||
| 4 | - <h1>Là où vous voulez aller</h1> | ||
| 5 | - </header> | ||
| 6 | 3 | ||
| 7 | - <p class="notice" role="status" aria-live="polite"> | 4 | + <ui-notice> |
| 8 | <ng-container *ngIf="justVisited"> | 5 | <ng-container *ngIf="justVisited"> |
| 9 | {{ justVisited }} rejoint vos lieux visités. <a routerLink="/resume">Voir le résumé</a> | 6 | {{ justVisited }} rejoint vos lieux visités. <a routerLink="/resume">Voir le résumé</a> |
| 10 | </ng-container> | 7 | </ng-container> |
| 11 | - </p> | 8 | + </ui-notice> |
| 12 | 9 | ||
| 13 | <div class="layout"> | 10 | <div class="layout"> |
| 14 | - <section class="card" aria-labelledby="wishlist-title"> | 11 | + <ui-card aria-labelledby="wishlist-title"> |
| 15 | <h2 id="wishlist-title"> | 12 | <h2 id="wishlist-title"> |
| 16 | {{ store.wishlist().length }} {{ store.wishlist().length > 1 ? 'destinations' : 'destination' }} | 13 | {{ store.wishlist().length }} {{ store.wishlist().length > 1 ? 'destinations' : 'destination' }} |
| 17 | </h2> | 14 | </h2> |
| @@ -20,9 +17,9 @@ | |||
| 20 | <li class="wish" *ngFor="let place of store.wishlist()"> | 17 | <li class="wish" *ngFor="let place of store.wishlist()"> |
| 21 | <div class="wish__head"> | 18 | <div class="wish__head"> |
| 22 | <strong>{{ place.name }}</strong> | 19 | <strong>{{ place.name }}</strong> |
| 23 | - <span class="badge" [ngClass]="'badge--' + (place.priority || 'medium')"> | 20 | + <ui-badge [tone]="place.priority === 'high' ? 'warm' : 'accent'"> |
| 24 | {{ priorityLabels[place.priority || 'medium'] }} | 21 | {{ priorityLabels[place.priority || 'medium'] }} |
| 25 | - </span> | 22 | + </ui-badge> |
| 26 | </div> | 23 | </div> |
| 27 | <span class="muted">{{ place.country }} · {{ categoryLabels[place.category] }}</span> | 24 | <span class="muted">{{ place.country }} · {{ categoryLabels[place.category] }}</span> |
| 28 | <p class="wish__note" *ngIf="place.note">{{ place.note }}</p> | 25 | <p class="wish__note" *ngIf="place.note">{{ place.note }}</p> |
| @@ -30,7 +27,7 @@ | |||
| 30 | <div class="actions"> | 27 | <div class="actions"> |
| 31 | <button | 28 | <button |
| 32 | type="button" | 29 | type="button" |
| 33 | - class="btn btn--primary btn--sm" | 30 | + ui-button variant="primary" size="sm" |
| 34 | [attr.aria-label]="'Marquer ' + place.name + ' comme visité'" | 31 | [attr.aria-label]="'Marquer ' + place.name + ' comme visité'" |
| 35 | (click)="markVisited(place)" | 32 | (click)="markVisited(place)" |
| 36 | > | 33 | > |
| @@ -40,18 +37,19 @@ | |||
| 40 | <ng-container *ngIf="pendingRemoval === place.id; else removeButton"> | 37 | <ng-container *ngIf="pendingRemoval === place.id; else removeButton"> |
| 41 | <button | 38 | <button |
| 42 | type="button" | 39 | type="button" |
| 43 | - class="btn btn--danger btn--sm" | 40 | + ui-button variant="danger" size="sm" |
| 44 | [attr.aria-label]="'Confirmer la suppression de ' + place.name" | 41 | [attr.aria-label]="'Confirmer la suppression de ' + place.name" |
| 45 | (click)="confirmRemoval(place)" | 42 | (click)="confirmRemoval(place)" |
| 46 | > | 43 | > |
| 47 | Confirmer | 44 | Confirmer |
| 48 | </button> | 45 | </button> |
| 49 | - <button type="button" class="btn btn--sm" (click)="cancelRemoval()">Annuler</button> | 46 | + <button type="button" ui-button size="sm" (click)="cancelRemoval()">Annuler</button> |
| 50 | </ng-container> | 47 | </ng-container> |
| 51 | <ng-template #removeButton> | 48 | <ng-template #removeButton> |
| 52 | <button | 49 | <button |
| 53 | type="button" | 50 | type="button" |
| 54 | - class="btn btn--sm" | 51 | + ui-button |
| 52 | + size="sm" | ||
| 55 | [attr.aria-label]="'Supprimer ' + place.name" | 53 | [attr.aria-label]="'Supprimer ' + place.name" |
| 56 | (click)="askRemoval(place)" | 54 | (click)="askRemoval(place)" |
| 57 | > | 55 | > |
| @@ -63,13 +61,13 @@ | |||
| 63 | </ul> | 61 | </ul> |
| 64 | 62 | ||
| 65 | <ng-template #emptyList> | 63 | <ng-template #emptyList> |
| 66 | - <p class="empty"> | 64 | + <ui-empty-state> |
| 67 | Votre liste est vide. <a routerLink="/lieux/nouveau">Ajoutez une destination</a> qui vous fait rêver. | 65 | Votre liste est vide. <a routerLink="/lieux/nouveau">Ajoutez une destination</a> qui vous fait rêver. |
| 68 | - </p> | 66 | + </ui-empty-state> |
| 69 | </ng-template> | 67 | </ng-template> |
| 70 | - </section> | 68 | + </ui-card> |
| 71 | 69 | ||
| 72 | - <section class="card" aria-labelledby="wish-map-title"> | 70 | + <ui-card aria-labelledby="wish-map-title"> |
| 73 | <h2 id="wish-map-title">Sur la carte</h2> | 71 | <h2 id="wish-map-title">Sur la carte</h2> |
| 74 | <div class="map-slot"> | 72 | <div class="map-slot"> |
| 75 | <app-travel-map | 73 | <app-travel-map |
| @@ -77,6 +75,6 @@ | |||
| 77 | label="Carte des destinations de votre liste de souhaits" | 75 | label="Carte des destinations de votre liste de souhaits" |
| 78 | ></app-travel-map> | 76 | ></app-travel-map> |
| 79 | </div> | 77 | </div> |
| 80 | - </section> | 78 | + </ui-card> |
| 81 | </div> | 79 | </div> |
| 82 | </section> | 80 | </section> |
modified
src/app/travel/pages/wishlist-page/wishlist-page.component.spec.ts +2 -2 | @@ -41,7 +41,7 @@ describe('WishlistPageComponent', () => { | ||
| 41 | 41 | |
| 42 | 42 | it('affiche un message quand la liste est vide', async () => { |
| 43 | 43 | await setup('[]'); |
| 44 | - expect(root.querySelector('.empty')?.textContent).toContain('Votre liste est vide'); | |
| 44 | + expect(root.querySelector('ui-empty-state')?.textContent).toContain('Votre liste est vide'); | |
| 45 | 45 | }); |
| 46 | 46 | |
| 47 | 47 | it('marque un souhait comme visité et prévient l’utilisateur', async () => { |
| @@ -52,7 +52,7 @@ describe('WishlistPageComponent', () => { | ||
| 52 | 52 | |
| 53 | 53 | expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeFalse(); |
| 54 | 54 | expect(store.visited().some((place) => place.name === 'Kyoto')).toBeTrue(); |
| 55 | - expect(root.querySelector('.notice')?.textContent).toContain('Kyoto rejoint vos lieux visités'); | |
| 55 | + expect(root.querySelector('ui-notice')?.textContent).toContain('Kyoto rejoint vos lieux visités'); | |
| 56 | 56 | }); |
| 57 | 57 | |
| 58 | 58 | it('demande une confirmation avant de supprimer', async () => { |
| @@ -41,7 +41,7 @@ describe('WishlistPageComponent', () => { | |||
| 41 | 41 | ||
| 42 | it('affiche un message quand la liste est vide', async () => { | 42 | it('affiche un message quand la liste est vide', async () => { |
| 43 | await setup('[]'); | 43 | await setup('[]'); |
| 44 | - expect(root.querySelector('.empty')?.textContent).toContain('Votre liste est vide'); | 44 | + expect(root.querySelector('ui-empty-state')?.textContent).toContain('Votre liste est vide'); |
| 45 | }); | 45 | }); |
| 46 | 46 | ||
| 47 | it('marque un souhait comme visité et prévient l’utilisateur', async () => { | 47 | it('marque un souhait comme visité et prévient l’utilisateur', async () => { |
| @@ -52,7 +52,7 @@ describe('WishlistPageComponent', () => { | |||
| 52 | 52 | ||
| 53 | expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeFalse(); | 53 | expect(store.wishlist().some((place) => place.name === 'Kyoto')).toBeFalse(); |
| 54 | expect(store.visited().some((place) => place.name === 'Kyoto')).toBeTrue(); | 54 | expect(store.visited().some((place) => place.name === 'Kyoto')).toBeTrue(); |
| 55 | - expect(root.querySelector('.notice')?.textContent).toContain('Kyoto rejoint vos lieux visités'); | 55 | + expect(root.querySelector('ui-notice')?.textContent).toContain('Kyoto rejoint vos lieux visités'); |
| 56 | }); | 56 | }); |
| 57 | 57 | ||
| 58 | it('demande une confirmation avant de supprimer', async () => { | 58 | it('demande une confirmation avant de supprimer', async () => { |
modified
src/app/travel/pages/wishlist-page/wishlist-page.component.ts +19 -1 | @@ -1,6 +1,14 @@ | ||
| 1 | 1 | import { CommonModule } from '@angular/common'; |
| 2 | 2 | import { Component } from '@angular/core'; |
| 3 | 3 | import { RouterLink } from '@angular/router'; |
| 4 | +import { | |
| 5 | + UiBadgeComponent, | |
| 6 | + UiButtonComponent, | |
| 7 | + UiCardComponent, | |
| 8 | + UiEmptyStateComponent, | |
| 9 | + UiNoticeComponent, | |
| 10 | + UiPageHeaderComponent, | |
| 11 | +} from 'ui-kit'; | |
| 4 | 12 | import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; |
| 5 | 13 | import { CATEGORY_LABELS, PRIORITY_LABELS, Place } from '../../models/place.model'; |
| 6 | 14 | import { TravelStore } from '../../services/travel-store.service'; |
| @@ -9,7 +17,17 @@ import { todayIso } from '../../utils/date'; | ||
| 9 | 17 | @Component({ |
| 10 | 18 | selector: 'app-wishlist-page', |
| 11 | 19 | standalone: true, |
| 12 | - imports: [CommonModule, RouterLink, TravelMapComponent], | |
| 20 | + imports: [ | |
| 21 | + CommonModule, | |
| 22 | + RouterLink, | |
| 23 | + TravelMapComponent, | |
| 24 | + UiBadgeComponent, | |
| 25 | + UiButtonComponent, | |
| 26 | + UiCardComponent, | |
| 27 | + UiEmptyStateComponent, | |
| 28 | + UiNoticeComponent, | |
| 29 | + UiPageHeaderComponent, | |
| 30 | + ], | |
| 13 | 31 | templateUrl: './wishlist-page.component.html', |
| 14 | 32 | }) |
| 15 | 33 | export class WishlistPageComponent { |
| @@ -1,6 +1,14 @@ | |||
| 1 | import { CommonModule } from '@angular/common'; | 1 | import { CommonModule } from '@angular/common'; |
| 2 | import { Component } from '@angular/core'; | 2 | import { Component } from '@angular/core'; |
| 3 | import { RouterLink } from '@angular/router'; | 3 | import { RouterLink } from '@angular/router'; |
| 4 | +import { | ||
| 5 | + UiBadgeComponent, | ||
| 6 | + UiButtonComponent, | ||
| 7 | + UiCardComponent, | ||
| 8 | + UiEmptyStateComponent, | ||
| 9 | + UiNoticeComponent, | ||
| 10 | + UiPageHeaderComponent, | ||
| 11 | +} from 'ui-kit'; | ||
| 4 | import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; | 12 | import { TravelMapComponent } from '../../components/travel-map/travel-map.component'; |
| 5 | import { CATEGORY_LABELS, PRIORITY_LABELS, Place } from '../../models/place.model'; | 13 | import { CATEGORY_LABELS, PRIORITY_LABELS, Place } from '../../models/place.model'; |
| 6 | import { TravelStore } from '../../services/travel-store.service'; | 14 | import { TravelStore } from '../../services/travel-store.service'; |
| @@ -9,7 +17,17 @@ import { todayIso } from '../../utils/date'; | |||
| 9 | @Component({ | 17 | @Component({ |
| 10 | selector: 'app-wishlist-page', | 18 | selector: 'app-wishlist-page', |
| 11 | standalone: true, | 19 | standalone: true, |
| 12 | - imports: [CommonModule, RouterLink, TravelMapComponent], | 20 | + imports: [ |
| 21 | + CommonModule, | ||
| 22 | + RouterLink, | ||
| 23 | + TravelMapComponent, | ||
| 24 | + UiBadgeComponent, | ||
| 25 | + UiButtonComponent, | ||
| 26 | + UiCardComponent, | ||
| 27 | + UiEmptyStateComponent, | ||
| 28 | + UiNoticeComponent, | ||
| 29 | + UiPageHeaderComponent, | ||
| 30 | + ], | ||
| 13 | templateUrl: './wishlist-page.component.html', | 31 | templateUrl: './wishlist-page.component.html', |
| 14 | }) | 32 | }) |
| 15 | export class WishlistPageComponent { | 33 | export class WishlistPageComponent { |
modified
src/styles.css +14 -229 | @@ -1,42 +1,21 @@ | ||
| 1 | 1 | @import 'leaflet/dist/leaflet.css'; |
| 2 | +@import 'ui-kit/styles/theme.css'; | |
| 2 | 3 | |
| 4 | +/* Jetons propres à l'application, alignés sur le thème de la bibliothèque ui-kit. */ | |
| 3 | 5 | :root { |
| 4 | - color-scheme: light dark; | |
| 6 | + --bg: var(--ui-bg); | |
| 7 | + --surface: var(--ui-surface); | |
| 8 | + --ink: var(--ui-ink); | |
| 9 | + --muted: var(--ui-muted); | |
| 10 | + --line: var(--ui-line); | |
| 11 | + --visited: var(--ui-accent); | |
| 12 | + --visited-soft: var(--ui-accent-soft); | |
| 13 | + --wish: var(--ui-warm); | |
| 14 | + --wish-soft: var(--ui-warm-soft); | |
| 5 | 15 | |
| 6 | - --bg: #f5f1ea; | |
| 7 | - --surface: #fffdf9; | |
| 8 | - --ink: #1d2a2f; | |
| 9 | - --muted: #5a686d; | |
| 10 | - --line: #e4ddd0; | |
| 11 | - --visited: #0f766e; | |
| 12 | - --visited-soft: #d9efea; | |
| 13 | - --wish: #b8400c; | |
| 14 | - --wish-soft: #fde6d8; | |
| 15 | - --danger: #b42318; | |
| 16 | - --on-accent: #ffffff; | |
| 17 | - --shadow: 0 1px 2px rgb(29 42 47 / 0.06), 0 10px 28px rgb(29 42 47 / 0.07); | |
| 18 | - | |
| 19 | - --radius: 14px; | |
| 20 | 16 | --gutter: clamp(1rem, 4vw, 2.5rem); |
| 21 | - --font-body: 'Avenir Next', 'Segoe UI', system-ui, -apple-system, sans-serif; | |
| 22 | - --font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif; | |
| 23 | -} | |
| 24 | - | |
| 25 | -@media (prefers-color-scheme: dark) { | |
| 26 | - :root { | |
| 27 | - --bg: #11181b; | |
| 28 | - --surface: #182226; | |
| 29 | - --ink: #eef2f0; | |
| 30 | - --muted: #a3b3b6; | |
| 31 | - --line: #2b393e; | |
| 32 | - --visited: #46c6b7; | |
| 33 | - --visited-soft: #17332f; | |
| 34 | - --wish: #f5a06c; | |
| 35 | - --wish-soft: #3a2619; | |
| 36 | - --danger: #ff8f84; | |
| 37 | - --on-accent: #0b1416; | |
| 38 | - --shadow: 0 1px 2px rgb(0 0 0 / 0.4); | |
| 39 | - } | |
| 17 | + --font-body: var(--ui-font-body); | |
| 18 | + --font-display: var(--ui-font-display); | |
| 40 | 19 | } |
| 41 | 20 | |
| 42 | 21 | *, |
| @@ -85,19 +64,6 @@ a { | ||
| 85 | 64 | padding: 1.75rem var(--gutter) 1rem; |
| 86 | 65 | } |
| 87 | 66 | |
| 88 | -.page__header { | |
| 89 | - margin-bottom: 1.5rem; | |
| 90 | -} | |
| 91 | - | |
| 92 | -.eyebrow { | |
| 93 | - margin: 0 0 0.25rem; | |
| 94 | - color: var(--wish); | |
| 95 | - font-size: 0.8rem; | |
| 96 | - font-weight: 700; | |
| 97 | - letter-spacing: 0.08em; | |
| 98 | - text-transform: uppercase; | |
| 99 | -} | |
| 100 | - | |
| 101 | 67 | .layout { |
| 102 | 68 | display: grid; |
| 103 | 69 | gap: 1.25rem; |
| @@ -125,17 +91,7 @@ a { | ||
| 125 | 91 | margin-top: 0.75rem; |
| 126 | 92 | } |
| 127 | 93 | |
| 128 | -/* ---------- Cartes et statistiques ---------- */ | |
| 129 | - | |
| 130 | -.card { | |
| 131 | - padding: 1.25rem; | |
| 132 | - border: 1px solid var(--line); | |
| 133 | - border-radius: var(--radius); | |
| 134 | - background: var(--surface); | |
| 135 | - box-shadow: var(--shadow); | |
| 136 | -} | |
| 137 | - | |
| 138 | -.card__head { | |
| 94 | +.card-head { | |
| 139 | 95 | display: flex; |
| 140 | 96 | flex-wrap: wrap; |
| 141 | 97 | align-items: baseline; |
| @@ -152,20 +108,6 @@ a { | ||
| 152 | 108 | list-style: none; |
| 153 | 109 | } |
| 154 | 110 | |
| 155 | -.stat { | |
| 156 | - display: flex; | |
| 157 | - flex-direction: column; | |
| 158 | -} | |
| 159 | - | |
| 160 | -.stat__value { | |
| 161 | - color: var(--visited); | |
| 162 | - font-family: var(--font-display); | |
| 163 | - font-size: 2.4rem; | |
| 164 | - font-weight: 700; | |
| 165 | - line-height: 1; | |
| 166 | -} | |
| 167 | - | |
| 168 | -.stat__label, | |
| 169 | 111 | .muted, |
| 170 | 112 | .hint { |
| 171 | 113 | color: var(--muted); |
| @@ -176,42 +118,6 @@ a { | ||
| 176 | 118 | font-size: 0.85rem; |
| 177 | 119 | } |
| 178 | 120 | |
| 179 | -.legend { | |
| 180 | - display: flex; | |
| 181 | - gap: 1rem; | |
| 182 | - margin: 0; | |
| 183 | - padding: 0; | |
| 184 | - color: var(--muted); | |
| 185 | - font-size: 0.85rem; | |
| 186 | - list-style: none; | |
| 187 | -} | |
| 188 | - | |
| 189 | -.legend__dot { | |
| 190 | - display: inline-block; | |
| 191 | - width: 0.8rem; | |
| 192 | - height: 0.8rem; | |
| 193 | - margin-right: 0.35rem; | |
| 194 | - border-radius: 50%; | |
| 195 | - vertical-align: -0.1rem; | |
| 196 | -} | |
| 197 | - | |
| 198 | -.legend__dot--visited { | |
| 199 | - background: var(--visited); | |
| 200 | -} | |
| 201 | - | |
| 202 | -.legend__dot--wishlist { | |
| 203 | - border: 2px dashed var(--wish); | |
| 204 | -} | |
| 205 | - | |
| 206 | -.empty, | |
| 207 | -.notice { | |
| 208 | - margin: 1rem 0; | |
| 209 | -} | |
| 210 | - | |
| 211 | -.notice:empty { | |
| 212 | - display: none; | |
| 213 | -} | |
| 214 | - | |
| 215 | 121 | /* ---------- Listes ---------- */ |
| 216 | 122 | |
| 217 | 123 | .timeline, |
| @@ -250,57 +156,6 @@ a { | ||
| 250 | 156 | margin: 0.2rem 0 0; |
| 251 | 157 | } |
| 252 | 158 | |
| 253 | -.badge { | |
| 254 | - padding: 0.1rem 0.6rem; | |
| 255 | - border-radius: 999px; | |
| 256 | - background: var(--visited-soft); | |
| 257 | - color: var(--visited); | |
| 258 | - font-size: 0.78rem; | |
| 259 | - font-weight: 700; | |
| 260 | -} | |
| 261 | - | |
| 262 | -.badge--high { | |
| 263 | - background: var(--wish-soft); | |
| 264 | - color: var(--wish); | |
| 265 | -} | |
| 266 | - | |
| 267 | -/* ---------- Boutons ---------- */ | |
| 268 | - | |
| 269 | -.btn { | |
| 270 | - display: inline-flex; | |
| 271 | - align-items: center; | |
| 272 | - justify-content: center; | |
| 273 | - padding: 0.65rem 1.1rem; | |
| 274 | - border: 1px solid var(--line); | |
| 275 | - border-radius: 10px; | |
| 276 | - background: var(--surface); | |
| 277 | - color: var(--ink); | |
| 278 | - font: inherit; | |
| 279 | - font-weight: 600; | |
| 280 | - text-decoration: none; | |
| 281 | - cursor: pointer; | |
| 282 | -} | |
| 283 | - | |
| 284 | -.btn:hover { | |
| 285 | - border-color: var(--visited); | |
| 286 | -} | |
| 287 | - | |
| 288 | -.btn--primary { | |
| 289 | - border-color: var(--visited); | |
| 290 | - background: var(--visited); | |
| 291 | - color: var(--on-accent); | |
| 292 | -} | |
| 293 | - | |
| 294 | -.btn--danger { | |
| 295 | - border-color: var(--danger); | |
| 296 | - color: var(--danger); | |
| 297 | -} | |
| 298 | - | |
| 299 | -.btn--sm { | |
| 300 | - padding: 0.4rem 0.8rem; | |
| 301 | - font-size: 0.88rem; | |
| 302 | -} | |
| 303 | - | |
| 304 | 159 | .actions { |
| 305 | 160 | display: flex; |
| 306 | 161 | flex-wrap: wrap; |
| @@ -308,8 +163,6 @@ a { | ||
| 308 | 163 | margin-top: 0.6rem; |
| 309 | 164 | } |
| 310 | 165 | |
| 311 | -/* ---------- Formulaires ---------- */ | |
| 312 | - | |
| 313 | 166 | .form { |
| 314 | 167 | display: grid; |
| 315 | 168 | gap: 1.1rem; |
| @@ -320,71 +173,3 @@ a { | ||
| 320 | 173 | gap: 1rem; |
| 321 | 174 | grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); |
| 322 | 175 | } |
| 323 | - | |
| 324 | -.field { | |
| 325 | - display: grid; | |
| 326 | - gap: 0.35rem; | |
| 327 | - align-content: start; | |
| 328 | -} | |
| 329 | - | |
| 330 | -.field label, | |
| 331 | -.segmented legend { | |
| 332 | - font-size: 0.9rem; | |
| 333 | - font-weight: 600; | |
| 334 | -} | |
| 335 | - | |
| 336 | -input[type='text'], | |
| 337 | -input[type='number'], | |
| 338 | -input[type='date'], | |
| 339 | -select, | |
| 340 | -textarea { | |
| 341 | - width: 100%; | |
| 342 | - padding: 0.65rem 0.8rem; | |
| 343 | - border: 1px solid var(--line); | |
| 344 | - border-radius: 10px; | |
| 345 | - background: var(--bg); | |
| 346 | - color: var(--ink); | |
| 347 | - font: inherit; | |
| 348 | -} | |
| 349 | - | |
| 350 | -input.ng-invalid.ng-touched, | |
| 351 | -select.ng-invalid.ng-touched, | |
| 352 | -textarea.ng-invalid.ng-touched { | |
| 353 | - border-color: var(--danger); | |
| 354 | -} | |
| 355 | - | |
| 356 | -.field__error { | |
| 357 | - margin: 0; | |
| 358 | - color: var(--danger); | |
| 359 | - font-size: 0.85rem; | |
| 360 | -} | |
| 361 | - | |
| 362 | -.segmented { | |
| 363 | - display: flex; | |
| 364 | - flex-wrap: wrap; | |
| 365 | - gap: 0.5rem; | |
| 366 | - margin: 0; | |
| 367 | - padding: 0; | |
| 368 | - border: 0; | |
| 369 | -} | |
| 370 | - | |
| 371 | -.segmented legend { | |
| 372 | - width: 100%; | |
| 373 | - margin-bottom: 0.35rem; | |
| 374 | - padding: 0; | |
| 375 | -} | |
| 376 | - | |
| 377 | -.segmented label { | |
| 378 | - display: inline-flex; | |
| 379 | - align-items: center; | |
| 380 | - gap: 0.5rem; | |
| 381 | - padding: 0.55rem 0.9rem; | |
| 382 | - border: 1px solid var(--line); | |
| 383 | - border-radius: 999px; | |
| 384 | - cursor: pointer; | |
| 385 | -} | |
| 386 | - | |
| 387 | -.segmented label:has(input:checked) { | |
| 388 | - border-color: var(--visited); | |
| 389 | - background: var(--visited-soft); | |
| 390 | -} | |
| @@ -1,42 +1,21 @@ | |||
| 1 | @import 'leaflet/dist/leaflet.css'; | 1 | @import 'leaflet/dist/leaflet.css'; |
| 2 | +@import 'ui-kit/styles/theme.css'; | ||
| 2 | 3 | ||
| 4 | +/* Jetons propres à l'application, alignés sur le thème de la bibliothèque ui-kit. */ | ||
| 3 | :root { | 5 | :root { |
| 4 | - color-scheme: light dark; | 6 | + --bg: var(--ui-bg); |
| 7 | + --surface: var(--ui-surface); | ||
| 8 | + --ink: var(--ui-ink); | ||
| 9 | + --muted: var(--ui-muted); | ||
| 10 | + --line: var(--ui-line); | ||
| 11 | + --visited: var(--ui-accent); | ||
| 12 | + --visited-soft: var(--ui-accent-soft); | ||
| 13 | + --wish: var(--ui-warm); | ||
| 14 | + --wish-soft: var(--ui-warm-soft); | ||
| 5 | 15 | ||
| 6 | - --bg: #f5f1ea; | ||
| 7 | - --surface: #fffdf9; | ||
| 8 | - --ink: #1d2a2f; | ||
| 9 | - --muted: #5a686d; | ||
| 10 | - --line: #e4ddd0; | ||
| 11 | - --visited: #0f766e; | ||
| 12 | - --visited-soft: #d9efea; | ||
| 13 | - --wish: #b8400c; | ||
| 14 | - --wish-soft: #fde6d8; | ||
| 15 | - --danger: #b42318; | ||
| 16 | - --on-accent: #ffffff; | ||
| 17 | - --shadow: 0 1px 2px rgb(29 42 47 / 0.06), 0 10px 28px rgb(29 42 47 / 0.07); | ||
| 18 | - | ||
| 19 | - --radius: 14px; | ||
| 20 | --gutter: clamp(1rem, 4vw, 2.5rem); | 16 | --gutter: clamp(1rem, 4vw, 2.5rem); |
| 21 | - --font-body: 'Avenir Next', 'Segoe UI', system-ui, -apple-system, sans-serif; | 17 | + --font-body: var(--ui-font-body); |
| 22 | - --font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif; | 18 | + --font-display: var(--ui-font-display); |
| 23 | -} | ||
| 24 | - | ||
| 25 | -@media (prefers-color-scheme: dark) { | ||
| 26 | - :root { | ||
| 27 | - --bg: #11181b; | ||
| 28 | - --surface: #182226; | ||
| 29 | - --ink: #eef2f0; | ||
| 30 | - --muted: #a3b3b6; | ||
| 31 | - --line: #2b393e; | ||
| 32 | - --visited: #46c6b7; | ||
| 33 | - --visited-soft: #17332f; | ||
| 34 | - --wish: #f5a06c; | ||
| 35 | - --wish-soft: #3a2619; | ||
| 36 | - --danger: #ff8f84; | ||
| 37 | - --on-accent: #0b1416; | ||
| 38 | - --shadow: 0 1px 2px rgb(0 0 0 / 0.4); | ||
| 39 | - } | ||
| 40 | } | 19 | } |
| 41 | 20 | ||
| 42 | *, | 21 | *, |
| @@ -85,19 +64,6 @@ a { | |||
| 85 | padding: 1.75rem var(--gutter) 1rem; | 64 | padding: 1.75rem var(--gutter) 1rem; |
| 86 | } | 65 | } |
| 87 | 66 | ||
| 88 | -.page__header { | ||
| 89 | - margin-bottom: 1.5rem; | ||
| 90 | -} | ||
| 91 | - | ||
| 92 | -.eyebrow { | ||
| 93 | - margin: 0 0 0.25rem; | ||
| 94 | - color: var(--wish); | ||
| 95 | - font-size: 0.8rem; | ||
| 96 | - font-weight: 700; | ||
| 97 | - letter-spacing: 0.08em; | ||
| 98 | - text-transform: uppercase; | ||
| 99 | -} | ||
| 100 | - | ||
| 101 | .layout { | 67 | .layout { |
| 102 | display: grid; | 68 | display: grid; |
| 103 | gap: 1.25rem; | 69 | gap: 1.25rem; |
| @@ -125,17 +91,7 @@ a { | |||
| 125 | margin-top: 0.75rem; | 91 | margin-top: 0.75rem; |
| 126 | } | 92 | } |
| 127 | 93 | ||
| 128 | -/* ---------- Cartes et statistiques ---------- */ | 94 | +.card-head { |
| 129 | - | ||
| 130 | -.card { | ||
| 131 | - padding: 1.25rem; | ||
| 132 | - border: 1px solid var(--line); | ||
| 133 | - border-radius: var(--radius); | ||
| 134 | - background: var(--surface); | ||
| 135 | - box-shadow: var(--shadow); | ||
| 136 | -} | ||
| 137 | - | ||
| 138 | -.card__head { | ||
| 139 | display: flex; | 95 | display: flex; |
| 140 | flex-wrap: wrap; | 96 | flex-wrap: wrap; |
| 141 | align-items: baseline; | 97 | align-items: baseline; |
| @@ -152,20 +108,6 @@ a { | |||
| 152 | list-style: none; | 108 | list-style: none; |
| 153 | } | 109 | } |
| 154 | 110 | ||
| 155 | -.stat { | ||
| 156 | - display: flex; | ||
| 157 | - flex-direction: column; | ||
| 158 | -} | ||
| 159 | - | ||
| 160 | -.stat__value { | ||
| 161 | - color: var(--visited); | ||
| 162 | - font-family: var(--font-display); | ||
| 163 | - font-size: 2.4rem; | ||
| 164 | - font-weight: 700; | ||
| 165 | - line-height: 1; | ||
| 166 | -} | ||
| 167 | - | ||
| 168 | -.stat__label, | ||
| 169 | .muted, | 111 | .muted, |
| 170 | .hint { | 112 | .hint { |
| 171 | color: var(--muted); | 113 | color: var(--muted); |
| @@ -176,42 +118,6 @@ a { | |||
| 176 | font-size: 0.85rem; | 118 | font-size: 0.85rem; |
| 177 | } | 119 | } |
| 178 | 120 | ||
| 179 | -.legend { | ||
| 180 | - display: flex; | ||
| 181 | - gap: 1rem; | ||
| 182 | - margin: 0; | ||
| 183 | - padding: 0; | ||
| 184 | - color: var(--muted); | ||
| 185 | - font-size: 0.85rem; | ||
| 186 | - list-style: none; | ||
| 187 | -} | ||
| 188 | - | ||
| 189 | -.legend__dot { | ||
| 190 | - display: inline-block; | ||
| 191 | - width: 0.8rem; | ||
| 192 | - height: 0.8rem; | ||
| 193 | - margin-right: 0.35rem; | ||
| 194 | - border-radius: 50%; | ||
| 195 | - vertical-align: -0.1rem; | ||
| 196 | -} | ||
| 197 | - | ||
| 198 | -.legend__dot--visited { | ||
| 199 | - background: var(--visited); | ||
| 200 | -} | ||
| 201 | - | ||
| 202 | -.legend__dot--wishlist { | ||
| 203 | - border: 2px dashed var(--wish); | ||
| 204 | -} | ||
| 205 | - | ||
| 206 | -.empty, | ||
| 207 | -.notice { | ||
| 208 | - margin: 1rem 0; | ||
| 209 | -} | ||
| 210 | - | ||
| 211 | -.notice:empty { | ||
| 212 | - display: none; | ||
| 213 | -} | ||
| 214 | - | ||
| 215 | /* ---------- Listes ---------- */ | 121 | /* ---------- Listes ---------- */ |
| 216 | 122 | ||
| 217 | .timeline, | 123 | .timeline, |
| @@ -250,57 +156,6 @@ a { | |||
| 250 | margin: 0.2rem 0 0; | 156 | margin: 0.2rem 0 0; |
| 251 | } | 157 | } |
| 252 | 158 | ||
| 253 | -.badge { | ||
| 254 | - padding: 0.1rem 0.6rem; | ||
| 255 | - border-radius: 999px; | ||
| 256 | - background: var(--visited-soft); | ||
| 257 | - color: var(--visited); | ||
| 258 | - font-size: 0.78rem; | ||
| 259 | - font-weight: 700; | ||
| 260 | -} | ||
| 261 | - | ||
| 262 | -.badge--high { | ||
| 263 | - background: var(--wish-soft); | ||
| 264 | - color: var(--wish); | ||
| 265 | -} | ||
| 266 | - | ||
| 267 | -/* ---------- Boutons ---------- */ | ||
| 268 | - | ||
| 269 | -.btn { | ||
| 270 | - display: inline-flex; | ||
| 271 | - align-items: center; | ||
| 272 | - justify-content: center; | ||
| 273 | - padding: 0.65rem 1.1rem; | ||
| 274 | - border: 1px solid var(--line); | ||
| 275 | - border-radius: 10px; | ||
| 276 | - background: var(--surface); | ||
| 277 | - color: var(--ink); | ||
| 278 | - font: inherit; | ||
| 279 | - font-weight: 600; | ||
| 280 | - text-decoration: none; | ||
| 281 | - cursor: pointer; | ||
| 282 | -} | ||
| 283 | - | ||
| 284 | -.btn:hover { | ||
| 285 | - border-color: var(--visited); | ||
| 286 | -} | ||
| 287 | - | ||
| 288 | -.btn--primary { | ||
| 289 | - border-color: var(--visited); | ||
| 290 | - background: var(--visited); | ||
| 291 | - color: var(--on-accent); | ||
| 292 | -} | ||
| 293 | - | ||
| 294 | -.btn--danger { | ||
| 295 | - border-color: var(--danger); | ||
| 296 | - color: var(--danger); | ||
| 297 | -} | ||
| 298 | - | ||
| 299 | -.btn--sm { | ||
| 300 | - padding: 0.4rem 0.8rem; | ||
| 301 | - font-size: 0.88rem; | ||
| 302 | -} | ||
| 303 | - | ||
| 304 | .actions { | 159 | .actions { |
| 305 | display: flex; | 160 | display: flex; |
| 306 | flex-wrap: wrap; | 161 | flex-wrap: wrap; |
| @@ -308,8 +163,6 @@ a { | |||
| 308 | margin-top: 0.6rem; | 163 | margin-top: 0.6rem; |
| 309 | } | 164 | } |
| 310 | 165 | ||
| 311 | -/* ---------- Formulaires ---------- */ | ||
| 312 | - | ||
| 313 | .form { | 166 | .form { |
| 314 | display: grid; | 167 | display: grid; |
| 315 | gap: 1.1rem; | 168 | gap: 1.1rem; |
| @@ -320,71 +173,3 @@ a { | |||
| 320 | gap: 1rem; | 173 | gap: 1rem; |
| 321 | grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); | 174 | grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); |
| 322 | } | 175 | } |
| 323 | - | ||
| 324 | -.field { | ||
| 325 | - display: grid; | ||
| 326 | - gap: 0.35rem; | ||
| 327 | - align-content: start; | ||
| 328 | -} | ||
| 329 | - | ||
| 330 | -.field label, | ||
| 331 | -.segmented legend { | ||
| 332 | - font-size: 0.9rem; | ||
| 333 | - font-weight: 600; | ||
| 334 | -} | ||
| 335 | - | ||
| 336 | -input[type='text'], | ||
| 337 | -input[type='number'], | ||
| 338 | -input[type='date'], | ||
| 339 | -select, | ||
| 340 | -textarea { | ||
| 341 | - width: 100%; | ||
| 342 | - padding: 0.65rem 0.8rem; | ||
| 343 | - border: 1px solid var(--line); | ||
| 344 | - border-radius: 10px; | ||
| 345 | - background: var(--bg); | ||
| 346 | - color: var(--ink); | ||
| 347 | - font: inherit; | ||
| 348 | -} | ||
| 349 | - | ||
| 350 | -input.ng-invalid.ng-touched, | ||
| 351 | -select.ng-invalid.ng-touched, | ||
| 352 | -textarea.ng-invalid.ng-touched { | ||
| 353 | - border-color: var(--danger); | ||
| 354 | -} | ||
| 355 | - | ||
| 356 | -.field__error { | ||
| 357 | - margin: 0; | ||
| 358 | - color: var(--danger); | ||
| 359 | - font-size: 0.85rem; | ||
| 360 | -} | ||
| 361 | - | ||
| 362 | -.segmented { | ||
| 363 | - display: flex; | ||
| 364 | - flex-wrap: wrap; | ||
| 365 | - gap: 0.5rem; | ||
| 366 | - margin: 0; | ||
| 367 | - padding: 0; | ||
| 368 | - border: 0; | ||
| 369 | -} | ||
| 370 | - | ||
| 371 | -.segmented legend { | ||
| 372 | - width: 100%; | ||
| 373 | - margin-bottom: 0.35rem; | ||
| 374 | - padding: 0; | ||
| 375 | -} | ||
| 376 | - | ||
| 377 | -.segmented label { | ||
| 378 | - display: inline-flex; | ||
| 379 | - align-items: center; | ||
| 380 | - gap: 0.5rem; | ||
| 381 | - padding: 0.55rem 0.9rem; | ||
| 382 | - border: 1px solid var(--line); | ||
| 383 | - border-radius: 999px; | ||
| 384 | - cursor: pointer; | ||
| 385 | -} | ||
| 386 | - | ||
| 387 | -.segmented label:has(input:checked) { | ||
| 388 | - border-color: var(--visited); | ||
| 389 | - background: var(--visited-soft); | ||
| 390 | -} | ||