From acd531f69e8a922d4dacfcd791f32e1b25d6c19d Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 27 May 2026 12:11:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Ajout=20des=20composants=20modal,=20acc?= =?UTF-8?q?ordeon,=20datetime=20avec=20selecteur=20d'heure=20=C3=A0=20la?= =?UTF-8?q?=20molette=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié --------- Co-authored-by: THOLOT DECHENE Matthieu Co-authored-by: matthieu Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/56 Co-authored-by: tristan Co-committed-by: tristan --- .claude/settings.local.json | 7 +- .../pages/composant/accordion/accordion.vue | 63 + .../pages/composant/filtre/filtres.vue | 88 + .playground/pages/composant/modal/modal.vue | 74 + .../pages/composant/time/timePicker.vue | 45 + .playground/playground.nav.ts | 4 + CHANGELOG.md | 3 + COMPONENTS.md | 161 +- .../malio/accordion/Accordion.test.ts | 256 +++ app/components/malio/accordion/Accordion.vue | 109 ++ .../malio/accordion/AccordionItem.test.ts | 48 + .../malio/accordion/AccordionItem.vue | 126 ++ app/components/malio/accordion/context.ts | 19 + app/components/malio/date/DateTime.test.ts | 25 +- app/components/malio/date/DateTime.vue | 33 +- app/components/malio/modal/Modal.test.ts | 320 ++++ app/components/malio/modal/Modal.vue | 279 ++++ app/components/malio/time/TimePicker.test.ts | 76 + app/components/malio/time/TimePicker.vue | 236 +++ .../malio/time/composables/timeFormat.test.ts | 31 + .../malio/time/composables/timeFormat.ts | 32 + .../time/composables/useInfiniteWheel.test.ts | 120 ++ .../time/composables/useInfiniteWheel.ts | 117 ++ .../malio/time/internal/TimeWheel.test.ts | 41 + .../malio/time/internal/TimeWheel.vue | 95 ++ .../malio/time/internal/TimeWheels.test.ts | 48 + .../malio/time/internal/TimeWheels.vue | 54 + app/story/accordion/accordion.story.vue | 125 ++ app/story/modal/modal.story.vue | 70 + app/story/time/timePicker.story.vue | 41 + .../superpowers/plans/2026-05-26-accordion.md | 1010 ++++++++++++ docs/superpowers/plans/2026-05-26-modal.md | 979 +++++++++++ .../plans/2026-05-27-select-heure.md | 1427 +++++++++++++++++ .../specs/2026-05-26-accordion-design.md | 167 ++ .../specs/2026-05-26-modal-design.md | 109 ++ .../specs/2026-05-27-select-heure-design.md | 173 ++ 36 files changed, 6581 insertions(+), 30 deletions(-) create mode 100644 .playground/pages/composant/accordion/accordion.vue create mode 100644 .playground/pages/composant/filtre/filtres.vue create mode 100644 .playground/pages/composant/modal/modal.vue create mode 100644 .playground/pages/composant/time/timePicker.vue create mode 100644 app/components/malio/accordion/Accordion.test.ts create mode 100644 app/components/malio/accordion/Accordion.vue create mode 100644 app/components/malio/accordion/AccordionItem.test.ts create mode 100644 app/components/malio/accordion/AccordionItem.vue create mode 100644 app/components/malio/accordion/context.ts create mode 100644 app/components/malio/modal/Modal.test.ts create mode 100644 app/components/malio/modal/Modal.vue create mode 100644 app/components/malio/time/TimePicker.test.ts create mode 100644 app/components/malio/time/TimePicker.vue create mode 100644 app/components/malio/time/composables/timeFormat.test.ts create mode 100644 app/components/malio/time/composables/timeFormat.ts create mode 100644 app/components/malio/time/composables/useInfiniteWheel.test.ts create mode 100644 app/components/malio/time/composables/useInfiniteWheel.ts create mode 100644 app/components/malio/time/internal/TimeWheel.test.ts create mode 100644 app/components/malio/time/internal/TimeWheel.vue create mode 100644 app/components/malio/time/internal/TimeWheels.test.ts create mode 100644 app/components/malio/time/internal/TimeWheels.vue create mode 100644 app/story/accordion/accordion.story.vue create mode 100644 app/story/modal/modal.story.vue create mode 100644 app/story/time/timePicker.story.vue create mode 100644 docs/superpowers/plans/2026-05-26-accordion.md create mode 100644 docs/superpowers/plans/2026-05-26-modal.md create mode 100644 docs/superpowers/plans/2026-05-27-select-heure.md create mode 100644 docs/superpowers/specs/2026-05-26-accordion-design.md create mode 100644 docs/superpowers/specs/2026-05-26-modal-design.md create mode 100644 docs/superpowers/specs/2026-05-27-select-heure-design.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 278a4af..8ae878a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,12 @@ "Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)", "Bash(mv inputCheckbox.story.vue checkbox/)", "Bash(npx eslint *)", - "Bash(echo \"LINT EXIT: $?\")" + "Bash(echo \"LINT EXIT: $?\")", + "Bash(git commit *)", + "mcp__chrome__navigate_page", + "mcp__chrome__take_snapshot", + "mcp__chrome__click", + "mcp__chrome__evaluate_script" ] } } diff --git a/.playground/pages/composant/accordion/accordion.vue b/.playground/pages/composant/accordion/accordion.vue new file mode 100644 index 0000000..c8aeb8d --- /dev/null +++ b/.playground/pages/composant/accordion/accordion.vue @@ -0,0 +1,63 @@ + + + diff --git a/.playground/pages/composant/filtre/filtres.vue b/.playground/pages/composant/filtre/filtres.vue new file mode 100644 index 0000000..9a3ed44 --- /dev/null +++ b/.playground/pages/composant/filtre/filtres.vue @@ -0,0 +1,88 @@ + + + diff --git a/.playground/pages/composant/modal/modal.vue b/.playground/pages/composant/modal/modal.vue new file mode 100644 index 0000000..4e23c5d --- /dev/null +++ b/.playground/pages/composant/modal/modal.vue @@ -0,0 +1,74 @@ + + + diff --git a/.playground/pages/composant/time/timePicker.vue b/.playground/pages/composant/time/timePicker.vue new file mode 100644 index 0000000..127342c --- /dev/null +++ b/.playground/pages/composant/time/timePicker.vue @@ -0,0 +1,45 @@ + + + diff --git a/.playground/playground.nav.ts b/.playground/playground.nav.ts index 4c2c976..a3c4a58 100644 --- a/.playground/playground.nav.ts +++ b/.playground/playground.nav.ts @@ -34,6 +34,7 @@ export const navSections: SidebarSection[] = [ {label: 'Semaine', to: '/composant/date/dateWeek'}, {label: 'Date & heure', to: '/composant/date/datetime'}, {label: 'Heure', to: '/composant/time/time'}, + {label: 'Sélecteur d\'heure', to: '/composant/time/timePicker'}, ], }, { @@ -52,7 +53,9 @@ export const navSections: SidebarSection[] = [ items: [ {label: 'Sidebar', to: '/composant/sidebar/sidebar'}, {label: 'Drawer', to: '/composant/drawer/drawer'}, + {label: 'Modal', to: '/composant/modal/modal'}, {label: 'Onglets', to: '/composant/tab/tabList'}, + {label: 'Accordéon', to: '/composant/accordion/accordion'}, ], }, { @@ -69,6 +72,7 @@ export const navSections: SidebarSection[] = [ {label: 'Heure', to: '/composant/time/time'}, {label: 'Sélecteur de site', to: '/composant/site/siteSelector'}, {label: 'Formulaire client', to: '/composant/form/client'}, + {label: 'Filtres', to: '/composant/filtre/filtres'}, ], }, ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 50620d0..b5b0e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,9 @@ Liste des évolutions de la librairie Malio layer UI * [#MUI-34] Revoir le système de playground * [#MUI-33] Développer le composant Datepicker * [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire) +* [#MUI-36] Création d'un composant modal (dialogue centré, focus-trap, scroll-lock, footer fixe) +* [#MUI-37] Création d'un composant accordéon +* [#MUI-39] Création d'un sélecteur d'heure à molettes (MalioTimePicker) ; DateTime rebranché dessus (remplace l'input time natif intérimaire) ### Changed * [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`. diff --git a/COMPONENTS.md b/COMPONENTS.md index 5a03fbb..6fe7b65 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -563,11 +563,40 @@ Sélecteur d'heure. --- +## MalioTimePicker + +Sélecteur d'heure à **molettes style iOS** (champ + popover). Deux colonnes infinies (heures `00–23`, minutes `00–59`, pas de 1) avec une bande de sélection centrale ; la valeur centrée est sélectionnée. Défilement, clic sur une valeur (recentrage) ou flèches clavier (`role="spinbutton"`). Pour une saisie clavier directe au format texte, voir plutôt `MalioTime`. + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `id` | `string` | auto | Identifiant HTML | +| `name` | `string` | `''` | Attribut name | +| `label` | `string` | `''` | Label flottant | +| `modelValue` | `string \| null` | `undefined` | Heure au format `"HH:MM"` (v-model) | +| `placeholder` | `string` | `'HH:MM'` | Placeholder | +| `required` | `boolean` | `false` | Champ requis | +| `disabled` | `boolean` | `false` | Désactive le champ | +| `readonly` | `boolean` | `false` | Lecture seule | +| `clearable` | `boolean` | `true` | Affiche la croix d'effacement | +| `hint` | `string` | `''` | Message d'aide | +| `error` | `string` | `''` | Message d'erreur | +| `success` | `string` | `''` | Message de succès | +| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes | + +**Events :** `update:modelValue(value: string | null)` + +```vue + + +``` + +--- + ## MalioDateTime Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille). -> ⚠️ **Version intérimaire** : le sélecteur d'heure est un `` natif, en attendant la maquette d'un sélecteur d'heure dédié. Le bloc heure est isolé pour être remplacé sans impact sur le reste. +> Depuis MUI-39, le réglage de l'heure utilise le sélecteur à molettes (cf. `MalioTimePicker`), qui remplace l'ancien `` natif intérimaire. La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front. @@ -694,6 +723,54 @@ const tabs = computed(() => [ --- +## MalioAccordion + +Accordéon compositionnel : `` enveloppe des ``. Plusieurs panneaux ouverts (`multiple`, défaut) ou un seul (`single`). Pensé pour les filtres en drawer et les FAQ. + +### MalioAccordion + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `mode` | `'single' \| 'multiple'` | `'multiple'` | Un seul ou plusieurs panneaux ouverts | +| `modelValue` | `string \| string[]` | `undefined` | Clés ouvertes (v-model). `string[]` en `multiple`, `string` en `single` | +| `id` | `string` | auto | Préfixe des IDs d'accessibilité | +| `groupClass` | `string` | `''` | Classes du conteneur (twMerge) | + +**Events :** `update:modelValue(value: string | string[])` + +### MalioAccordionItem + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `title` | `string` | — | Texte de l'en-tête | +| `value` | `string` | auto | Clé unique de la section | +| `defaultOpen` | `boolean` | `false` | Ouvert au montage (mode non contrôlé) | +| `disabled` | `boolean` | `false` | En-tête non cliquable | +| `headerClass` | `string` | `''` | Override classes en-tête (twMerge) | +| `panelClass` | `string` | `''` | Override classes panneau (twMerge) | + +**Slot :** par défaut = contenu du panneau. + +```vue + + + + + + + + + + + + + Réponse 1 + Réponse 2 + +``` + +--- + ## MalioSidebar Barre latérale de navigation rétractable. @@ -779,6 +856,58 @@ Panneau latéral (drawer) qui s'ouvre depuis la droite ou la gauche avec backdro --- +## MalioModal + +Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'accessibilité (focus-trap, restitution du focus, `Échap`), le verrouillage du scroll de la page et un empilement correct de plusieurs modals. Structure : header fixe, body scrollable (`max-h-[85vh]`), footer fixe. + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `id` | `string` | auto | Identifiant HTML | +| `modelValue` | `boolean` | `undefined` | État ouvert/fermé (v-model) | +| `showClose` | `boolean` | `true` | Afficher le bouton de fermeture (croix) | +| `dismissable` | `boolean` | `true` | Fermer au clic sur le backdrop | +| `closeOnEscape` | `boolean` | `true` | Fermer avec la touche `Échap` | +| `ariaLabel` | `string` | `''` | Nom accessible de secours quand le slot `#header` est absent | +| `modalClass` | `string` | `''` | Classes CSS panneau, ex. largeur `max-w-lg` (twMerge) | +| `overlayClass` | `string` | `''` | Classes CSS backdrop (twMerge) | +| `headerClass` | `string` | `''` | Classes CSS barre header (twMerge) | +| `bodyClass` | `string` | `''` | Classes CSS zone scrollable (twMerge) | +| `footerClass` | `string` | `''` | Classes CSS footer fixe (twMerge) | + +**Events :** `update:modelValue(value: boolean)`, `close()` + +**Slots :** +- `header` — en-tête (titre, etc.). S'il est absent et que `showClose` est `true`, seule la croix est affichée. +- `default` — contenu (zone scrollable). +- `footer` — actions (boutons). Rendu en bas, fixe, séparé par une bordure. N'apparaît que si le slot est fourni. + +```vue + + +

Contenu de la modal

+
+ + + + + + + + + + + +

Fermeture via la croix uniquement

+
+``` + +--- + ## MalioDataTable Tableau de données presentational avec pagination, filtres par slots et lignes cliquables. @@ -832,3 +961,33 @@ Tableau de données presentational avec pagination, filtres par slots et lignes v-model:per-page="perPage" /> ``` + +--- + +## MalioSiteSelector + +Sélecteur de site sous forme de tuiles segmentées (`role="radiogroup"`). Chaque site occupe une tuile de largeur égale ; la tuile active s'affiche pleine opacité dans sa couleur (`site.color`), les autres sont atténuées. Pattern contrôlé (`v-model`) ou non contrôlé (premier site sélectionné par défaut). + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `sites` | `{ id: string, name: string, color: string }[]` | **requis** | Liste des sites (la `color` colore la tuile active) | +| `modelValue` | `string` | `undefined` | `id` du site sélectionné (v-model) | +| `id` | `string` | auto | Identifiant HTML du conteneur | +| `groupClass` | `string` | `''` | Classes CSS du conteneur (twMerge) | +| `tileClass` | `string` | `''` | Classes CSS de chaque tuile (twMerge) | +| `labelClass` | `string` | `''` | Classes CSS du label de tuile (twMerge) | + +**Events :** +- `update:modelValue(value: string)` — `id` du site sélectionné (v-model) +- `change(site: Site)` — émis avec l'objet site complet sélectionné + +```vue + +``` diff --git a/app/components/malio/accordion/Accordion.test.ts b/app/components/malio/accordion/Accordion.test.ts new file mode 100644 index 0000000..376c88a --- /dev/null +++ b/app/components/malio/accordion/Accordion.test.ts @@ -0,0 +1,256 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import {nextTick} from 'vue' +import Accordion from './Accordion.vue' +import AccordionItem from './AccordionItem.vue' + +const TWO_ITEMS = ` +

Contenu prix

+

Contenu catégorie

+` + +function mountAccordion(props: Record = {}, slot: string = TWO_ITEMS, attachTo?: HTMLElement) { + return mount(Accordion, { + props, + slots: {default: slot}, + attachTo, + global: {components: {MalioAccordionItem: AccordionItem}}, + }) +} + +describe('MalioAccordion — rendu & mode multiple', () => { + it('renders each item header with its title', () => { + const wrapper = mountAccordion() + const headers = wrapper.findAll('button[aria-expanded]') + expect(headers).toHaveLength(2) + expect(headers[0].text()).toContain('Prix') + expect(headers[1].text()).toContain('Catégorie') + }) + + it('renders the slot content of each panel', () => { + const wrapper = mountAccordion() + expect(wrapper.html()).toContain('Contenu prix') + expect(wrapper.html()).toContain('Contenu catégorie') + }) + + it('all panels are collapsed by default', () => { + const wrapper = mountAccordion() + const headers = wrapper.findAll('button[aria-expanded]') + expect(headers[0].attributes('aria-expanded')).toBe('false') + expect(headers[1].attributes('aria-expanded')).toBe('false') + const regions = wrapper.findAll('[role="region"]') + expect(regions[0].classes()).toContain('grid-rows-[0fr]') + }) + + it('opens a panel on header click (multiple mode is default)', async () => { + const wrapper = mountAccordion() + const headers = wrapper.findAll('button[aria-expanded]') + await headers[0].trigger('click') + expect(headers[0].attributes('aria-expanded')).toBe('true') + const regions = wrapper.findAll('[role="region"]') + expect(regions[0].classes()).toContain('grid-rows-[1fr]') + }) + + it('keeps multiple panels open simultaneously in multiple mode', async () => { + const wrapper = mountAccordion() + const headers = wrapper.findAll('button[aria-expanded]') + await headers[0].trigger('click') + await headers[1].trigger('click') + expect(headers[0].attributes('aria-expanded')).toBe('true') + expect(headers[1].attributes('aria-expanded')).toBe('true') + }) + + it('closes an open panel when its header is clicked again', async () => { + const wrapper = mountAccordion() + const headers = wrapper.findAll('button[aria-expanded]') + await headers[0].trigger('click') + await headers[0].trigger('click') + expect(headers[0].attributes('aria-expanded')).toBe('false') + }) + + it('wires aria-controls / aria-labelledby / role=region correctly', () => { + const wrapper = mountAccordion({id: 'acc'}) + const headers = wrapper.findAll('button[aria-expanded]') + const regions = wrapper.findAll('[role="region"]') + expect(headers[0].attributes('id')).toBe('acc-header-prix') + expect(headers[0].attributes('aria-controls')).toBe('acc-panel-prix') + expect(regions[0].attributes('id')).toBe('acc-panel-prix') + expect(regions[0].attributes('aria-labelledby')).toBe('acc-header-prix') + }) + + it('emits update:modelValue with an array in multiple mode', async () => { + const wrapper = mountAccordion() + const headers = wrapper.findAll('button[aria-expanded]') + await headers[0].trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']]) + await nextTick() + }) +}) + +describe('MalioAccordion — mode single & contrôlé', () => { + it('opening a panel closes the others in single mode', async () => { + const wrapper = mountAccordion({mode: 'single'}) + const headers = wrapper.findAll('button[aria-expanded]') + await headers[0].trigger('click') + await headers[1].trigger('click') + expect(headers[0].attributes('aria-expanded')).toBe('false') + expect(headers[1].attributes('aria-expanded')).toBe('true') + }) + + it('emits a string in single mode', async () => { + const wrapper = mountAccordion({mode: 'single'}) + const headers = wrapper.findAll('button[aria-expanded]') + await headers[1].trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['cat']) + }) + + it('emits empty string when closing the open panel in single mode', async () => { + const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'}) + const headers = wrapper.findAll('button[aria-expanded]') + await headers[0].trigger('click') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['']) + }) + + it('respects modelValue array in controlled multiple mode', () => { + const wrapper = mountAccordion({modelValue: ['cat']}) + const headers = wrapper.findAll('button[aria-expanded]') + expect(headers[0].attributes('aria-expanded')).toBe('false') + expect(headers[1].attributes('aria-expanded')).toBe('true') + }) + + it('respects modelValue string in controlled single mode', () => { + const wrapper = mountAccordion({mode: 'single', modelValue: 'prix'}) + const headers = wrapper.findAll('button[aria-expanded]') + expect(headers[0].attributes('aria-expanded')).toBe('true') + expect(headers[1].attributes('aria-expanded')).toBe('false') + }) + + it('does not mutate local state in controlled mode (emits only)', async () => { + const wrapper = mountAccordion({modelValue: []}) + const headers = wrapper.findAll('button[aria-expanded]') + await headers[0].trigger('click') + // état piloté par le parent : sans mise à jour de la prop, reste fermé + expect(headers[0].attributes('aria-expanded')).toBe('false') + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['prix']]) + }) +}) + +describe('MalioAccordion — defaultOpen, disabled & clavier', () => { + const WITH_DEFAULT_OPEN = ` +

P

+

C

+ ` + const WITH_DISABLED = ` +

P

+

C

+ ` + + it('opens defaultOpen items initially in uncontrolled mode', async () => { + const wrapper = mountAccordion({}, WITH_DEFAULT_OPEN) + await nextTick() + const headers = wrapper.findAll('button[aria-expanded]') + expect(headers[0].attributes('aria-expanded')).toBe('false') + expect(headers[1].attributes('aria-expanded')).toBe('true') + }) + + it('sets disabled and aria-disabled on a disabled item', () => { + const wrapper = mountAccordion({}, WITH_DISABLED) + const headers = wrapper.findAll('button[aria-expanded]') + expect(headers[1].attributes('disabled')).toBeDefined() + expect(headers[1].attributes('aria-disabled')).toBe('true') + }) + + it('does not toggle a disabled item on click', async () => { + const wrapper = mountAccordion({}, WITH_DISABLED) + const headers = wrapper.findAll('button[aria-expanded]') + await headers[1].trigger('click') + expect(headers[1].attributes('aria-expanded')).toBe('false') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('moves focus to the next header on ArrowDown', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + const wrapper = mountAccordion({}, TWO_ITEMS, root) + const headers = wrapper.findAll('button[aria-expanded]') + ;(headers[0].element as HTMLElement).focus() + await headers[0].trigger('keydown', {key: 'ArrowDown'}) + expect(document.activeElement).toBe(headers[1].element) + wrapper.unmount() + root.remove() + }) + + it('wraps focus to the first header on ArrowDown from the last', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + const wrapper = mountAccordion({}, TWO_ITEMS, root) + const headers = wrapper.findAll('button[aria-expanded]') + ;(headers[1].element as HTMLElement).focus() + await headers[1].trigger('keydown', {key: 'ArrowDown'}) + expect(document.activeElement).toBe(headers[0].element) + wrapper.unmount() + root.remove() + }) + + it('moves focus to the previous header on ArrowUp', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + const wrapper = mountAccordion({}, TWO_ITEMS, root) + const headers = wrapper.findAll('button[aria-expanded]') + ;(headers[1].element as HTMLElement).focus() + await headers[1].trigger('keydown', {key: 'ArrowUp'}) + expect(document.activeElement).toBe(headers[0].element) + wrapper.unmount() + root.remove() + }) + + it('skips disabled headers during keyboard navigation', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + const slot = ` +

A

+

B

+

C

+ ` + const wrapper = mountAccordion({}, slot, root) + const headers = wrapper.findAll('button[aria-expanded]') + ;(headers[0].element as HTMLElement).focus() + await headers[0].trigger('keydown', {key: 'ArrowDown'}) + // saute le header désactivé (B) pour aller directement à C + expect(document.activeElement).toBe(headers[2].element) + wrapper.unmount() + root.remove() + }) +}) + +describe('MalioAccordion — overflow du panneau (popovers enfants)', () => { + const ONE = `

contenu

` + const ONE_OPEN = `

contenu

` + + it('clips the panel (overflow-hidden) while collapsed', () => { + const wrapper = mountAccordion({}, ONE) + const inner = wrapper.find('[role="region"] > div') + expect(inner.classes()).toContain('overflow-hidden') + expect(inner.classes()).not.toContain('overflow-visible') + }) + + it('lets the panel overflow once open at mount (defaultOpen)', async () => { + const wrapper = mountAccordion({}, ONE_OPEN) + await nextTick() + expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible') + }) + + it('switches to overflow-visible after the open transition ends', async () => { + const wrapper = mountAccordion({}, ONE) + await wrapper.find('button[aria-expanded]').trigger('click') + await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'}) + expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible') + }) + + it('re-clips (overflow-hidden) as soon as it closes', async () => { + const wrapper = mountAccordion({}, ONE_OPEN) + await nextTick() + await wrapper.find('button[aria-expanded]').trigger('click') + expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden') + }) +}) diff --git a/app/components/malio/accordion/Accordion.vue b/app/components/malio/accordion/Accordion.vue new file mode 100644 index 0000000..e3b3317 --- /dev/null +++ b/app/components/malio/accordion/Accordion.vue @@ -0,0 +1,109 @@ + + + diff --git a/app/components/malio/accordion/AccordionItem.test.ts b/app/components/malio/accordion/AccordionItem.test.ts new file mode 100644 index 0000000..00c742f --- /dev/null +++ b/app/components/malio/accordion/AccordionItem.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it, vi} from 'vitest' +import {mount} from '@vue/test-utils' +import Accordion from './Accordion.vue' +import AccordionItem from './AccordionItem.vue' + +function mountInAccordion(slot: string, accordionProps: Record = {}) { + return mount(Accordion, { + props: accordionProps, + slots: {default: slot}, + global: {components: {MalioAccordionItem: AccordionItem}}, + }) +} + +describe('MalioAccordionItem', () => { + it('throws when used outside MalioAccordion', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(() => mount(AccordionItem, {props: {title: 'Solo'}})).toThrow( + /à l'intérieur de MalioAccordion/, + ) + spy.mockRestore() + }) + + it('generates an auto id-based value and still toggles when value prop is omitted', async () => { + const wrapper = mountInAccordion( + `

X

`, + ) + const header = wrapper.find('button[aria-expanded]') + expect(header.attributes('aria-controls')).toMatch(/-panel-malio-accordion-item-/) + await header.trigger('click') + expect(header.attributes('aria-expanded')).toBe('true') + }) + + it('applies headerClass and panelClass overrides via twMerge', () => { + const wrapper = mountInAccordion( + `

X

`, + ) + const header = wrapper.find('button[aria-expanded]') + expect(header.classes()).toContain('bg-red-500') + expect(wrapper.find('[role="region"]').html()).toContain('text-lg') + }) + + it('renders a rotating chevron icon', () => { + const wrapper = mountInAccordion( + `

X

`, + ) + expect(wrapper.find('button[aria-expanded] svg').exists()).toBe(true) + }) +}) diff --git a/app/components/malio/accordion/AccordionItem.vue b/app/components/malio/accordion/AccordionItem.vue new file mode 100644 index 0000000..2589d3d --- /dev/null +++ b/app/components/malio/accordion/AccordionItem.vue @@ -0,0 +1,126 @@ + + + diff --git a/app/components/malio/accordion/context.ts b/app/components/malio/accordion/context.ts new file mode 100644 index 0000000..d5b1002 --- /dev/null +++ b/app/components/malio/accordion/context.ts @@ -0,0 +1,19 @@ +import type {ComputedRef, InjectionKey} from 'vue' + +export interface AccordionItemRegistration { + value: string + getHeaderEl: () => HTMLElement | null + isDisabled: () => boolean +} + +export interface AccordionContext { + mode: ComputedRef<'single' | 'multiple'> + baseId: ComputedRef + isOpen: (value: string) => boolean + toggle: (value: string) => void + register: (item: AccordionItemRegistration, defaultOpen: boolean) => void + unregister: (value: string) => void + focusSibling: (value: string, offset: 1 | -1) => void +} + +export const accordionContextKey: InjectionKey = Symbol('MalioAccordion') diff --git a/app/components/malio/date/DateTime.test.ts b/app/components/malio/date/DateTime.test.ts index e78d29d..803b861 100644 --- a/app/components/malio/date/DateTime.test.ts +++ b/app/components/malio/date/DateTime.test.ts @@ -2,6 +2,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' import {mount} from '@vue/test-utils' import type {DefineComponent} from 'vue' import DateTime_ from './DateTime.vue' +import MalioTimePicker from '../time/TimePicker.vue' type DateTimeProps = { id?: string @@ -30,7 +31,7 @@ const mountDateTime = (props: DateTimeProps = {}) => describe('MalioDateTime', () => { beforeEach(() => { vi.useFakeTimers() - vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + vi.setSystemTime(new Date(2026, 4, 19, 9, 5, 0)) // 19 mai 2026, 09:05 }) afterEach(() => vi.useRealTimers()) @@ -49,28 +50,30 @@ describe('MalioDateTime', () => { }) describe('popover', () => { - it('ouvre la grille et l\'input heure au clic', async () => { + it('ouvre la grille et le champ sélecteur d\'heure au clic', async () => { const wrapper = mountDateTime() await wrapper.get('[data-test="date-input"]').trigger('click') expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true) - expect(wrapper.find('[data-test="time-input"]').exists()).toBe(true) + expect(wrapper.findComponent(MalioTimePicker).exists()).toBe(true) + expect(wrapper.find('[data-test="time-field"]').exists()).toBe(true) }) }) describe('sélection', () => { - it('émet le jour à 00:00 et garde le popover ouvert', async () => { + it('émet le jour à l\'heure actuelle (si aucune heure choisie) et garde le popover ouvert', async () => { const wrapper = mountDateTime() await wrapper.get('[data-test="date-input"]').trigger('click') await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') - expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00']) + // heure système figée à 09:05 + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:05:00']) expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) }) it('applique l\'heure réglée avant le clic du jour', async () => { const wrapper = mountDateTime() await wrapper.get('[data-test="date-input"]').trigger('click') - await wrapper.get('[data-test="time-input"]').setValue('09:15') - // pas d'émission tant qu'aucun jour n'est choisi + wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '09:15') + await wrapper.vm.$nextTick() expect(wrapper.emitted('update:modelValue')).toBeUndefined() await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00']) @@ -79,15 +82,15 @@ describe('MalioDateTime', () => { it('met à jour l\'heure quand une date est déjà choisie', async () => { const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'}) await wrapper.get('[data-test="date-input"]').trigger('click') - await wrapper.get('[data-test="time-input"]').setValue('08:45') + wrapper.findComponent(MalioTimePicker).vm.$emit('update:modelValue', '08:45') + await wrapper.vm.$nextTick() expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00']) }) - it('initialise l\'input heure depuis la valeur', async () => { + it('initialise le champ heure depuis la valeur', async () => { const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'}) await wrapper.get('[data-test="date-input"]').trigger('click') - const time = wrapper.get('[data-test="time-input"]').element as HTMLInputElement - expect(time.value).toBe('14:30') + expect(wrapper.findComponent(MalioTimePicker).props('modelValue')).toBe('14:30') }) }) diff --git a/app/components/malio/date/DateTime.vue b/app/components/malio/date/DateTime.vue index 8cf7c25..fb62f46 100644 --- a/app/components/malio/date/DateTime.vue +++ b/app/components/malio/date/DateTime.vue @@ -28,25 +28,25 @@ :max="max?.slice(0, 10)" @select="onSelectDay" /> - -
- +
+
+ + + + diff --git a/app/components/malio/time/TimePicker.test.ts b/app/components/malio/time/TimePicker.test.ts new file mode 100644 index 0000000..5514458 --- /dev/null +++ b/app/components/malio/time/TimePicker.test.ts @@ -0,0 +1,76 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import TimePicker from './TimePicker.vue' + +type TimePickerProps = { + id?: string + name?: string + label?: string + modelValue?: string | null + placeholder?: string + required?: boolean + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string + clearable?: boolean + inputClass?: string + labelClass?: string + groupClass?: string +} + +const TimePickerForTest = TimePicker as DefineComponent +const mountPicker = (props: TimePickerProps = {}) => + mount(TimePickerForTest, {props, attachTo: document.body}) + +describe('MalioTimePicker', () => { + it('affiche le label et l\'icône horloge', () => { + const wrapper = mountPicker({label: 'Heure'}) + expect(wrapper.get('label').text()).toBe('Heure') + expect(wrapper.find('[data-test="clock-icon"]').exists()).toBe(true) + }) + + it('affiche la valeur HH:MM dans le champ', () => { + const wrapper = mountPicker({modelValue: '14:30'}) + const input = wrapper.get('[data-test="time-field"]').element as HTMLInputElement + expect(input.value).toBe('14:30') + }) + + it('ouvre le popover à molettes au clic', async () => { + const wrapper = mountPicker() + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + await wrapper.get('[data-test="time-field"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) + expect(wrapper.find('[data-test="time-wheels"]').exists()).toBe(true) + }) + + it('n\'ouvre pas le popover si disabled', async () => { + const wrapper = mountPicker({disabled: true}) + await wrapper.get('[data-test="time-field"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('émet la valeur réglée depuis les molettes', async () => { + const wrapper = mountPicker({modelValue: '09:30'}) + await wrapper.get('[data-test="time-field"]').trigger('click') + wrapper.findComponent({name: 'MalioTimeWheels'}).vm.$emit('update:modelValue', '10:30') + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['10:30']) + }) + + it('émet null au clic sur la croix', async () => { + const wrapper = mountPicker({modelValue: '14:30'}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) + }) + + it('positionne aria-invalid et describedby sur erreur', () => { + const wrapper = mountPicker({error: 'Heure requise'}) + const input = wrapper.get('[data-test="time-field"]') + expect(input.attributes('aria-invalid')).toBe('true') + expect(input.attributes('aria-describedby')).toBeTruthy() + expect(wrapper.text()).toContain('Heure requise') + }) +}) diff --git a/app/components/malio/time/TimePicker.vue b/app/components/malio/time/TimePicker.vue new file mode 100644 index 0000000..a292b77 --- /dev/null +++ b/app/components/malio/time/TimePicker.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/app/components/malio/time/composables/timeFormat.test.ts b/app/components/malio/time/composables/timeFormat.test.ts new file mode 100644 index 0000000..3eea58e --- /dev/null +++ b/app/components/malio/time/composables/timeFormat.test.ts @@ -0,0 +1,31 @@ +import {describe, expect, it} from 'vitest' +import {clampHours, clampMinutes, formatTime, padSegment, parseTime} from './timeFormat' + +describe('timeFormat', () => { + it('parse une chaîne HH:MM valide', () => { + expect(parseTime('09:05')).toEqual({hours: 9, minutes: 5}) + }) + + it('renvoie null pour vide ou invalide', () => { + expect(parseTime('')).toBeNull() + expect(parseTime(null)).toBeNull() + expect(parseTime('abc')).toBeNull() + expect(parseTime('12')).toBeNull() + }) + + it('clamp les valeurs hors bornes au parsing', () => { + expect(parseTime('99:88')).toEqual({hours: 23, minutes: 59}) + }) + + it('formate avec zéro-padding', () => { + expect(formatTime(9, 5)).toBe('09:05') + expect(formatTime(0, 0)).toBe('00:00') + }) + + it('clamp et pad les helpers', () => { + expect(clampHours(30)).toBe(23) + expect(clampHours(-2)).toBe(0) + expect(clampMinutes(75)).toBe(59) + expect(padSegment(7)).toBe('07') + }) +}) diff --git a/app/components/malio/time/composables/timeFormat.ts b/app/components/malio/time/composables/timeFormat.ts new file mode 100644 index 0000000..02221c2 --- /dev/null +++ b/app/components/malio/time/composables/timeFormat.ts @@ -0,0 +1,32 @@ +export interface TimeParts { + hours: number + minutes: number +} + +export function clampHours(value: number): number { + if (Number.isNaN(value)) return 0 + return Math.min(23, Math.max(0, Math.trunc(value))) +} + +export function clampMinutes(value: number): number { + if (Number.isNaN(value)) return 0 + return Math.min(59, Math.max(0, Math.trunc(value))) +} + +export function padSegment(value: number): string { + return value.toString().padStart(2, '0') +} + +export function parseTime(value: string | null | undefined): TimeParts | null { + if (!value) return null + const match = /^(\d{1,2}):(\d{1,2})$/.exec(value.trim()) + if (!match) return null + return { + hours: clampHours(Number.parseInt(match[1], 10)), + minutes: clampMinutes(Number.parseInt(match[2], 10)), + } +} + +export function formatTime(hours: number, minutes: number): string { + return `${padSegment(clampHours(hours))}:${padSegment(clampMinutes(minutes))}` +} diff --git a/app/components/malio/time/composables/useInfiniteWheel.test.ts b/app/components/malio/time/composables/useInfiniteWheel.test.ts new file mode 100644 index 0000000..88f3d76 --- /dev/null +++ b/app/components/malio/time/composables/useInfiniteWheel.test.ts @@ -0,0 +1,120 @@ +import {describe, expect, it, vi} from 'vitest' +import {defineComponent, nextTick, ref} from 'vue' +import {mount} from '@vue/test-utils' +import { + CENTER_OFFSET, + VISIBLE_ROWS, + loopCorrection, + scrollTopForValueIndex, + useInfiniteWheel, + valueIndexFromScroll, +} from './useInfiniteWheel' + +const H = 40 // itemHeight +const LEN = 24 // ex. heures + +describe('useInfiniteWheel — math pure', () => { + it('expose 5 lignes visibles et un offset central de 2', () => { + expect(VISIBLE_ROWS).toBe(5) + expect(CENTER_OFFSET).toBe(2) + }) + + it('scrollTopForValueIndex et valueIndexFromScroll font un aller-retour', () => { + for (const index of [0, 1, 9, 23]) { + const top = scrollTopForValueIndex(index, H, LEN) + expect(valueIndexFromScroll(top, H, LEN)).toBe(index) + } + }) + + it('valueIndexFromScroll boucle en modulo', () => { + const top = scrollTopForValueIndex(0, H, LEN) + expect(valueIndexFromScroll(top + LEN * H, H, LEN)).toBe(0) + }) + + it('loopCorrection laisse le scroll de la copie du milieu inchangé', () => { + const top = scrollTopForValueIndex(12, H, LEN) + expect(loopCorrection(top, H, LEN)).toBe(top) + }) + + it('loopCorrection ramène vers le milieu quand on dérive vers le haut', () => { + const drifted = scrollTopForValueIndex(0, H, LEN) - LEN * H + expect(loopCorrection(drifted, H, LEN)).toBe(drifted + LEN * H) + }) + + it('loopCorrection ramène vers le milieu quand on dérive vers le bas', () => { + const drifted = scrollTopForValueIndex(0, H, LEN) + LEN * H + expect(loopCorrection(drifted, H, LEN)).toBe(drifted - LEN * H) + }) +}) + +function mountWheelHarness(initialIndex: number, onChange: (i: number) => void) { + let api!: ReturnType + const Harness = defineComponent({ + setup() { + const container = ref(null) + api = useInfiniteWheel(container, { + length: 24, + itemHeight: 40, + initialIndex: () => initialIndex, + onChange, + }) + return {container} + }, + template: '
', + }) + const wrapper = mount(Harness, {attachTo: document.body}) + return {wrapper, api: () => api} +} + +describe('useInfiniteWheel — composable', () => { + it('step(+1) émet l\'index suivant', async () => { + const changes: number[] = [] + const {api} = mountWheelHarness(9, (i) => changes.push(i)) + await nextTick() + api().step(1) + expect(changes.at(-1)).toBe(10) + }) + + it('step boucle de 23 à 0', async () => { + const changes: number[] = [] + const {api} = mountWheelHarness(23, (i) => changes.push(i)) + await nextTick() + api().step(1) + expect(changes.at(-1)).toBe(0) + }) + + it('onKeydown ArrowUp décrémente (avec wrap)', async () => { + const changes: number[] = [] + const {api} = mountWheelHarness(0, (i) => changes.push(i)) + await nextTick() + api().onKeydown(new KeyboardEvent('keydown', {key: 'ArrowUp'})) + expect(changes.at(-1)).toBe(23) + }) + + // Anti-boucle navigateur : un scroll programmatique déclenche une rafale d'évènements + // scroll (animation/snap). Ils ne doivent PAS être pris pour du scroll utilisateur, + // sinon settle() ré-émet en boucle et corrompt le patch DOM de Vue. + it('n\'émet pas en double quand un scroll programmatique déclenche une rafale de scroll', async () => { + vi.useFakeTimers() + try { + const changes: number[] = [] + const {wrapper, api} = mountWheelHarness(9, (i) => changes.push(i)) + await nextTick() + const el = wrapper.element as HTMLElement + changes.length = 0 + + api().scrollToIndex(12) + + el.dispatchEvent(new Event('scroll')) + el.dispatchEvent(new Event('scroll')) + el.dispatchEvent(new Event('scroll')) + + vi.advanceTimersByTime(300) + + expect(changes).toEqual([12]) + } + finally { + vi.useRealTimers() + } + }) +}) diff --git a/app/components/malio/time/composables/useInfiniteWheel.ts b/app/components/malio/time/composables/useInfiniteWheel.ts new file mode 100644 index 0000000..97849e6 --- /dev/null +++ b/app/components/malio/time/composables/useInfiniteWheel.ts @@ -0,0 +1,117 @@ +import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue' + +export const VISIBLE_ROWS = 5 +export const CENTER_OFFSET = (VISIBLE_ROWS - 1) / 2 // 2 + +/** Index de valeur logique (0..length-1) centré pour un scrollTop donné. */ +export function valueIndexFromScroll(scrollTop: number, itemHeight: number, length: number): number { + const flat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET + return ((flat % length) + length) % length +} + +/** scrollTop qui centre l'index donné dans la copie du milieu (buffer à 3 copies). */ +export function scrollTopForValueIndex(valueIndex: number, itemHeight: number, length: number): number { + const flat = length + valueIndex - CENTER_OFFSET + return flat * itemHeight +} + +/** Recentre le scrollTop dans la copie du milieu [length, 2*length) si on a dérivé. */ +export function loopCorrection(scrollTop: number, itemHeight: number, length: number): number { + const block = length * itemHeight + const centeredFlat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET + if (centeredFlat < length) return scrollTop + block + if (centeredFlat >= 2 * length) return scrollTop - block + return scrollTop +} + +export interface UseInfiniteWheelOptions { + length: number + itemHeight: number + initialIndex: () => number + onChange: (index: number) => void +} + +export function useInfiniteWheel( + containerRef: Ref, + options: UseInfiniteWheelOptions, +) { + const centeredIndex = ref(options.initialIndex()) + let scrollEndTimer: ReturnType | null = null + // Fenêtre de suppression : ignore les évènements scroll provoqués par NOS + // repositionnements programmatiques (et les réajustements de scroll-snap), qui + // arrivent en rafale. Un booléen one-shot n'en absorberait qu'un seul : les + // suivants seraient pris pour du scroll utilisateur → settle() → onChange en + // boucle (re-render ré-entrant qui corrompt le patch DOM dans le navigateur). + let suppressed = false + let suppressTimer: ReturnType | null = null + + // Scroll programmatique INSTANTANÉ : pas de 'smooth', dont l'animation multi-frames + // émettrait justement la rafale d'évènements scroll problématique. + function applyScroll(top: number) { + const el = containerRef.value + if (!el) return + suppressed = true + if (suppressTimer) clearTimeout(suppressTimer) + suppressTimer = setTimeout(() => { suppressed = false }, 100) + el.scrollTop = top + } + + function readCentered() { + const el = containerRef.value + if (!el) return + centeredIndex.value = valueIndexFromScroll(el.scrollTop, options.itemHeight, options.length) + } + + function settle() { + const el = containerRef.value + if (!el) return + readCentered() + options.onChange(centeredIndex.value) + const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length) + if (corrected !== el.scrollTop) applyScroll(corrected) + } + + function onScroll() { + if (suppressed) return + readCentered() + if (scrollEndTimer) clearTimeout(scrollEndTimer) + scrollEndTimer = setTimeout(settle, 120) + } + + function scrollToIndex(index: number) { + centeredIndex.value = index + applyScroll(scrollTopForValueIndex(index, options.itemHeight, options.length)) + options.onChange(index) + } + + function step(delta: number) { + const next = (((centeredIndex.value + delta) % options.length) + options.length) % options.length + scrollToIndex(next) + } + + function onKeydown(event: KeyboardEvent) { + if (event.key === 'ArrowUp') { + event.preventDefault() + step(-1) + } + else if (event.key === 'ArrowDown') { + event.preventDefault() + step(1) + } + } + + onMounted(() => { + const el = containerRef.value + if (!el) return + el.addEventListener('scroll', onScroll, {passive: true}) + applyScroll(scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length)) + }) + + onBeforeUnmount(() => { + containerRef.value?.removeEventListener('scroll', onScroll) + if (scrollEndTimer) clearTimeout(scrollEndTimer) + if (suppressTimer) clearTimeout(suppressTimer) + }) + + return {centeredIndex, scrollToIndex, step, onKeydown} +} diff --git a/app/components/malio/time/internal/TimeWheel.test.ts b/app/components/malio/time/internal/TimeWheel.test.ts new file mode 100644 index 0000000..2c7b183 --- /dev/null +++ b/app/components/malio/time/internal/TimeWheel.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import TimeWheel from './TimeWheel.vue' + +const HOURS = Array.from({length: 24}, (_, i) => i) + +const mountWheel = (modelValue = 9) => + mount(TimeWheel, { + props: {modelValue, values: HOURS, ariaLabel: 'Heures'}, + attachTo: document.body, + }) + +describe('MalioTimeWheel', () => { + it('expose le rôle spinbutton et les attributs aria', () => { + const wrapper = mountWheel(9) + const el = wrapper.get('[role="spinbutton"]') + expect(el.attributes('aria-label')).toBe('Heures') + expect(el.attributes('aria-valuenow')).toBe('9') + expect(el.attributes('aria-valuemin')).toBe('0') + expect(el.attributes('aria-valuemax')).toBe('23') + expect(el.attributes('aria-valuetext')).toBe('09') + }) + + it('rend 3 copies des valeurs (buffer infini)', () => { + const wrapper = mountWheel() + expect(wrapper.findAll('[data-test="wheel-item"]')).toHaveLength(24 * 3) + }) + + it('émet la nouvelle valeur au clavier ArrowDown', async () => { + const wrapper = mountWheel(9) + await wrapper.get('[role="spinbutton"]').trigger('keydown', {key: 'ArrowDown'}) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([10]) + }) + + it('émet la valeur cliquée', async () => { + const wrapper = mountWheel(9) + const item = wrapper.findAll('[data-test="wheel-item"]').find((w) => w.text() === '11')! + await item.trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([11]) + }) +}) diff --git a/app/components/malio/time/internal/TimeWheel.vue b/app/components/malio/time/internal/TimeWheel.vue new file mode 100644 index 0000000..acea3e5 --- /dev/null +++ b/app/components/malio/time/internal/TimeWheel.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/app/components/malio/time/internal/TimeWheels.test.ts b/app/components/malio/time/internal/TimeWheels.test.ts new file mode 100644 index 0000000..9134def --- /dev/null +++ b/app/components/malio/time/internal/TimeWheels.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it} from 'vitest' +import {mount} from '@vue/test-utils' +import TimeWheels from './TimeWheels.vue' +import TimeWheel from './TimeWheel.vue' + +const mountWheels = (modelValue = '09:30') => + mount(TimeWheels, {props: {modelValue}, attachTo: document.body}) + +describe('MalioTimeWheels', () => { + it('rend deux molettes (heures + minutes) et un séparateur', () => { + const wrapper = mountWheels('09:30') + const wheels = wrapper.findAllComponents(TimeWheel) + expect(wheels).toHaveLength(2) + expect(wheels[0].props('ariaLabel')).toBe('Heures') + expect(wheels[1].props('ariaLabel')).toBe('Minutes') + expect(wrapper.text()).toContain(':') + }) + + it('splitte modelValue vers les bonnes molettes', () => { + const wrapper = mountWheels('09:30') + const wheels = wrapper.findAllComponents(TimeWheel) + expect(wheels[0].props('modelValue')).toBe(9) + expect(wheels[1].props('modelValue')).toBe(30) + }) + + it('recompose et émet HH:MM quand l\'heure change', async () => { + const wrapper = mountWheels('09:30') + const wheels = wrapper.findAllComponents(TimeWheel) + wheels[0].vm.$emit('update:modelValue', 14) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['14:30']) + }) + + it('recompose et émet HH:MM quand la minute change', async () => { + const wrapper = mountWheels('09:30') + const wheels = wrapper.findAllComponents(TimeWheel) + wheels[1].vm.$emit('update:modelValue', 5) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['09:05']) + }) + + it('par défaut 00:00 quand modelValue est vide', () => { + const wrapper = mountWheels('') + const wheels = wrapper.findAllComponents(TimeWheel) + expect(wheels[0].props('modelValue')).toBe(0) + expect(wheels[1].props('modelValue')).toBe(0) + }) +}) diff --git a/app/components/malio/time/internal/TimeWheels.vue b/app/components/malio/time/internal/TimeWheels.vue new file mode 100644 index 0000000..094cd65 --- /dev/null +++ b/app/components/malio/time/internal/TimeWheels.vue @@ -0,0 +1,54 @@ + + + diff --git a/app/story/accordion/accordion.story.vue b/app/story/accordion/accordion.story.vue new file mode 100644 index 0000000..3142e63 --- /dev/null +++ b/app/story/accordion/accordion.story.vue @@ -0,0 +1,125 @@ + + + +# MalioAccordion + +Accordéon compositionnel : un parent `MalioAccordion` qui enveloppe des +`MalioAccordionItem`. Conçu pour des systèmes de filtres (plusieurs sections +dépliées simultanément) comme pour des FAQ (une seule section ouverte). + +--- + +## Props — MalioAccordion + +### mode +- Type: `'single' | 'multiple'` +- Défaut: `'multiple'` +- Description: `multiple` autorise plusieurs panneaux ouverts ; `single` ferme les autres à l'ouverture. + +### modelValue +- Type: `string | string[]` +- Description: clés ouvertes. `string[]` en mode `multiple`, `string` en mode `single`. Sans v-model, état interne (non contrôlé). + +### id +- Type: `string` +- Description: préfixe des IDs d'accessibilité. Auto-généré si absent. + +### groupClass +- Type: `string` +- Description: classes du conteneur, fusionnées via `twMerge`. + +--- + +## Props — MalioAccordionItem + +### title +- Type: `string` (requis) — texte de l'en-tête. + +### value +- Type: `string` — clé unique de la section (recommandée pour piloter le v-model). Auto-générée si absente. + +### defaultOpen +- Type: `boolean` — défaut `false`. Ouvre la section au montage (mode non contrôlé uniquement). + +### disabled +- Type: `boolean` — défaut `false`. En-tête non cliquable. + +### headerClass / panelClass +- Type: `string` — override des classes de l'en-tête / du panneau (`twMerge`). + +--- + +## Slots + +Slot par défaut de `MalioAccordionItem` = contenu du panneau. + +--- + +## Accessibilité + +- En-tête = `