# Story 2.5: Compétences cliquables → Projets liés Status: review ## Story As a visiteur, I want cliquer sur une compétence pour voir les projets qui l'utilisent, so that je peux voir des preuves concrètes de maîtrise. ## Acceptance Criteria 1. **Given** le visiteur est sur la page Compétences **When** il clique sur une compétence **Then** un panneau/modal s'ouvre avec la liste des projets liés à cette compétence 2. **And** pour chaque projet lié : titre, description courte, lien vers le détail 3. **And** l'indication du niveau avant/après chaque projet est visible (progression) 4. **And** une animation d'ouverture/fermeture fluide est présente (respectant `prefers-reduced-motion`) 5. **And** la fermeture est possible par clic extérieur, bouton close, ou touche Escape 6. **And** le panneau/modal utilise Headless UI pour l'accessibilité 7. **And** la navigation au clavier est fonctionnelle (Tab, Escape, Enter) 8. **And** le focus est piégé dans le modal quand ouvert (`focus trap`) 9. **And** les données viennent de la relation `skill_project` via l'API ## Tasks / Subtasks - [x] **Task 1: Créer l'endpoint API pour les projets d'une compétence** (AC: #9) - [x] Ajouter méthode `projects($slug)` dans `SkillController` - [x] Charger les projets avec leur pivot (level_before, level_after) - [x] Retourner 404 si le skill n'existe pas - [x] Joindre les traductions - [x] **Task 2: Installer et configurer Headless UI** (AC: #6) - [x] Installer `@headlessui/vue` dans le frontend - [x] Vérifier la compatibilité avec Vue 3 / Nuxt 4 - [x] **Task 3: Créer le composant SkillProjectsModal** (AC: #1, #2, #3, #5, #6, #7, #8) - [x] Créer `frontend/app/components/feature/SkillProjectsModal.vue` - [x] Utiliser `Dialog` de Headless UI - [x] Props : isOpen, skill (avec name, description) - [x] Emit : close - [x] Afficher le titre de la compétence - [x] Afficher la description de la compétence - [x] Liste des projets liés - [x] **Task 4: Créer le composant ProjectListItem** (AC: #2, #3) - [x] Créer `frontend/app/components/feature/ProjectListItem.vue` - [x] Afficher titre, description courte, niveau avant/après - [x] Lien vers la page détail du projet - [x] Visualisation de la progression (flèche niveau) - [x] **Task 5: Charger les projets au clic** (AC: #9) - [x] Créer composable `useFetchSkillProjects(slug)` - [x] Appeler l'API quand le modal s'ouvre - [x] Gérer l'état loading/error dans le modal - [x] **Task 6: Implémenter les animations** (AC: #4) - [x] Animation d'ouverture : fade-in + scale - [x] Animation de fermeture : fade-out + scale - [x] Overlay avec backdrop blur - [x] Respecter `prefers-reduced-motion` - [x] **Task 7: Fermeture du modal** (AC: #5) - [x] Clic sur l'overlay ferme le modal - [x] Bouton close (X) en haut à droite - [x] Touche Escape ferme le modal - [x] Restaurer le focus à l'élément précédent - [x] **Task 8: Intégrer dans la page Compétences** (AC: #1) - [x] Modifier `competences.vue` pour ouvrir le modal - [x] Gérer l'état du modal (isOpen, selectedSkill) - [x] Passer les props au modal - [x] **Task 9: Tests et validation** - [x] Tester l'ouverture/fermeture - [x] Valider la navigation clavier (Tab, Escape) - [x] Tester le focus trap - [x] Vérifier l'accessibilité avec axe DevTools - [x] Tester en FR et EN - [x] Valider les animations ## Dev Notes ### Endpoint API Laravel ```php header('Accept-Language', 'fr'); $skill = Skill::with('projects') ->where('slug', $slug) ->first(); if (!$skill) { return response()->json([ 'error' => [ 'code' => 'SKILL_NOT_FOUND', 'message' => 'Skill not found', ] ], 404); } return response()->json([ 'data' => [ 'skill' => [ 'id' => $skill->id, 'slug' => $skill->slug, 'name' => Translation::getTranslation($skill->name_key, $lang), 'description' => Translation::getTranslation($skill->description_key, $lang), 'level' => $skill->getCurrentLevel(), 'maxLevel' => $skill->max_level, ], 'projects' => $skill->projects->map(function ($project) use ($lang) { return [ 'id' => $project->id, 'slug' => $project->slug, 'title' => Translation::getTranslation($project->title_key, $lang), 'shortDescription' => Translation::getTranslation($project->short_description_key, $lang), 'image' => $project->image, 'dateCompleted' => $project->date_completed?->format('Y-m-d'), 'levelBefore' => $project->pivot->level_before, 'levelAfter' => $project->pivot->level_after, 'levelDescription' => $project->pivot->level_description_key ? Translation::getTranslation($project->pivot->level_description_key, $lang) : null, ]; }), ], 'meta' => ['lang' => $lang], ]); } ``` ```php // api/routes/api.php Route::get('/skills/{slug}/projects', [SkillController::class, 'projects']); ``` ### Installation Headless UI ```bash cd frontend npm install @headlessui/vue ``` ### Composable useFetchSkillProjects ```typescript // frontend/app/composables/useFetchSkillProjects.ts import type { Skill } from '~/types/skill' import type { Project } from '~/types/project' interface SkillProjectsResponse { data: { skill: Skill projects: (Project & { levelBefore: number; levelAfter: number; levelDescription?: string })[] } meta: { lang: string } } export function useFetchSkillProjects(slug: Ref) { const config = useRuntimeConfig() const { locale } = useI18n() return useFetch( () => slug.value ? `/skills/${slug.value}/projects` : null, { baseURL: config.public.apiUrl, headers: { 'X-API-Key': config.public.apiKey, 'Accept-Language': locale.value, }, immediate: false, watch: false, } ) } ``` ### Composant SkillProjectsModal ```vue ``` ### Composant ProjectListItem ```vue ``` ### Modification de competences.vue ```vue ``` ### Clés i18n nécessaires **fr.json :** ```json { "skills": { "relatedProjects": "Projets utilisant cette compétence", "loadProjectsError": "Impossible de charger les projets liés", "noProjects": "Aucun projet n'utilise encore cette compétence", "level": "Niveau" }, "common": { "close": "Fermer" } } ``` **en.json :** ```json { "skills": { "relatedProjects": "Projects using this skill", "loadProjectsError": "Unable to load related projects", "noProjects": "No projects use this skill yet", "level": "Level" }, "common": { "close": "Close" } } ``` ### Accessibilité | Requirement | Implementation | |-------------|----------------| | Focus trap | Géré automatiquement par Headless UI Dialog | | Keyboard navigation | Tab entre les éléments, Escape pour fermer | | Screen reader | DialogTitle annoncé, aria-modal="true" | | Fermeture externe | Clic overlay, bouton X, Escape | | Focus restoration | Automatique par Headless UI | ### Dépendances **Cette story nécessite :** - Story 1.2 : Table skill_project avec relations - Story 2.4 : Page Compétences avec SkillCard cliquable **Cette story prépare pour :** - Aucune dépendance directe ### Project Structure Notes **Fichiers à créer :** ``` frontend/app/components/feature/ ├── SkillProjectsModal.vue # CRÉER └── ProjectListItem.vue # CRÉER frontend/app/composables/ └── useFetchSkillProjects.ts # CRÉER ``` **Fichiers à modifier :** ``` api/app/Http/Controllers/Api/SkillController.php # AJOUTER projects() api/routes/api.php # AJOUTER route frontend/app/pages/competences.vue # AJOUTER modal frontend/i18n/fr.json # AJOUTER clés frontend/i18n/en.json # AJOUTER clés frontend/package.json # AJOUTER @headlessui/vue ``` ### References - [Source: docs/planning-artifacts/epics.md#Story-2.5] - [Source: docs/planning-artifacts/architecture.md#Design-System-Components] - [Source: docs/planning-artifacts/ux-design-specification.md#Design-System-Components-Headless] - [Source: docs/planning-artifacts/ux-design-specification.md#Accessibility-Strategy] ### Technical Requirements | Requirement | Value | Source | |-------------|-------|--------| | UI Library | Headless UI Dialog | Architecture | | Focus trap | Required | WCAG AA | | Keyboard nav | Tab, Escape, Enter | WCAG AA | | Animation | Respect prefers-reduced-motion | NFR6 | | API endpoint | GET /api/skills/{slug}/projects | Architecture | ## Dev Agent Record ### Agent Model Used Claude Opus 4.5 (claude-opus-4-5-20251101) ### Debug Log References - Aucun problème majeur ### Completion Notes List - Endpoint API GET /skills/{slug}/projects créé avec projets liés et niveaux avant/après - @headlessui/vue installé et configuré - Composable useFetchSkillProjects créé avec appel différé (immediate: false) - SkillProjectsModal créé avec : - Headless UI Dialog pour accessibilité automatique - TransitionRoot/TransitionChild pour animations fade+scale - Backdrop blur overlay - États loading/error/empty - prefers-reduced-motion respecté - ProjectListItem créé : thumbnail, titre, description, progression niveau (+X) - Page competences.vue intégrée : état modal, handleSkillClick, closeModal - Focus trap et keyboard nav gérés automatiquement par Headless UI - Traductions FR/EN ajoutées (related_projects, load_projects_error, no_related_projects) ### Change Log | Date | Change | Author | |------|--------|--------| | 2026-02-04 | Story créée avec contexte complet | SM Agent | | 2026-02-06 | Tasks 1-9 implémentées et validées | Dev Agent (Claude Opus 4.5) | ### File List - `api/app/Http/Controllers/Api/SkillController.php` — MODIFIÉ (ajout projects()) - `api/routes/api.php` — MODIFIÉ (ajout route) - `frontend/package.json` — MODIFIÉ (@headlessui/vue) - `frontend/app/composables/useFetchSkillProjects.ts` — CRÉÉ - `frontend/app/components/feature/SkillProjectsModal.vue` — CRÉÉ - `frontend/app/components/feature/ProjectListItem.vue` — CRÉÉ - `frontend/app/pages/competences.vue` — MODIFIÉ (intégration modal) - `frontend/i18n/fr.json` — MODIFIÉ (ajout skills.*) - `frontend/i18n/en.json` — MODIFIÉ (ajout skills.*)