From 7ac097e7f09d30b0a368bba3a63dd30aaf6dfb14 Mon Sep 17 00:00:00 2001 From: tristan Date: Fri, 22 May 2026 07:56:07 +0000 Subject: [PATCH] =?UTF-8?q?[#MUI-33]=20D=C3=A9velopper=20le=20composant=20?= =?UTF-8?q?Datepicker=20(#50)?= 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 - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: https://gitea.malio.fr/MALIO-DEV/malio-layer-ui/pulls/50 Co-authored-by: tristan Co-committed-by: tristan --- .claude/settings.local.json | 4 +- .../skills/creating-malio-component/SKILL.md | 6 +- .playground/pages/composant/date/date.vue | 68 + .../pages/composant/date/dateRange.vue | 72 + .playground/pages/composant/date/dateWeek.vue | 68 + .playground/pages/composant/date/datetime.vue | 68 + .playground/pages/composant/form/client.vue | 7 +- .playground/playground.nav.ts | 12 +- CHANGELOG.md | 2 + COMPONENTS.md | 137 ++ app/assets/css/malio.css | 1 + app/components/malio/date/Date.test.ts | 198 +++ app/components/malio/date/Date.vue | 93 ++ app/components/malio/date/DateRange.test.ts | 155 ++ app/components/malio/date/DateRange.vue | 140 ++ app/components/malio/date/DateTime.test.ts | 120 ++ app/components/malio/date/DateTime.vue | 134 ++ app/components/malio/date/DateWeek.test.ts | 122 ++ app/components/malio/date/DateWeek.vue | 123 ++ .../malio/date/composables/dateFormat.test.ts | 62 + .../malio/date/composables/dateFormat.ts | 26 + .../malio/date/composables/dateRange.test.ts | 57 + .../malio/date/composables/dateRange.ts | 31 + .../malio/date/composables/dateWeek.test.ts | 74 + .../malio/date/composables/dateWeek.ts | 67 + .../date/composables/datetimeFormat.test.ts | 61 + .../malio/date/composables/datetimeFormat.ts | 33 + .../composables/useCalendarPopover.test.ts | 64 + .../date/composables/useCalendarPopover.ts | 28 + .../date/composables/useCalendarView.test.ts | 68 + .../malio/date/composables/useCalendarView.ts | 51 + .../date/composables/useMonthMatrix.test.ts | 69 + .../malio/date/composables/useMonthMatrix.ts | 60 + .../malio/date/internal/CalendarField.vue | 239 +++ .../malio/date/internal/CalendarHeader.vue | 70 + .../malio/date/internal/MonthGrid.vue | 178 ++ .../malio/date/internal/MonthPicker.vue | 36 + app/story/date/datePicker.story.vue | 94 ++ app/story/date/dateRange.story.vue | 77 + app/story/date/dateTime.story.vue | 76 + app/story/date/dateWeek.story.vue | 75 + .../plans/2026-05-20-datepicker.md | 1440 +++++++++++++++++ .../superpowers/plans/2026-05-20-daterange.md | 1362 ++++++++++++++++ docs/superpowers/plans/2026-05-20-dateweek.md | 780 +++++++++ docs/superpowers/plans/2026-05-22-datetime.md | 712 ++++++++ .../specs/2026-05-19-datepicker-design.md | 373 +++++ .../specs/2026-05-20-daterange-design.md | 243 +++ .../specs/2026-05-20-dateweek-design.md | 168 ++ .../specs/2026-05-22-datetime-design.md | 146 ++ nuxt.config.ts | 1 + tailwind.config.ts | 1 + 51 files changed, 8346 insertions(+), 6 deletions(-) create mode 100644 .playground/pages/composant/date/date.vue create mode 100644 .playground/pages/composant/date/dateRange.vue create mode 100644 .playground/pages/composant/date/dateWeek.vue create mode 100644 .playground/pages/composant/date/datetime.vue create mode 100644 app/components/malio/date/Date.test.ts create mode 100644 app/components/malio/date/Date.vue create mode 100644 app/components/malio/date/DateRange.test.ts create mode 100644 app/components/malio/date/DateRange.vue create mode 100644 app/components/malio/date/DateTime.test.ts create mode 100644 app/components/malio/date/DateTime.vue create mode 100644 app/components/malio/date/DateWeek.test.ts create mode 100644 app/components/malio/date/DateWeek.vue create mode 100644 app/components/malio/date/composables/dateFormat.test.ts create mode 100644 app/components/malio/date/composables/dateFormat.ts create mode 100644 app/components/malio/date/composables/dateRange.test.ts create mode 100644 app/components/malio/date/composables/dateRange.ts create mode 100644 app/components/malio/date/composables/dateWeek.test.ts create mode 100644 app/components/malio/date/composables/dateWeek.ts create mode 100644 app/components/malio/date/composables/datetimeFormat.test.ts create mode 100644 app/components/malio/date/composables/datetimeFormat.ts create mode 100644 app/components/malio/date/composables/useCalendarPopover.test.ts create mode 100644 app/components/malio/date/composables/useCalendarPopover.ts create mode 100644 app/components/malio/date/composables/useCalendarView.test.ts create mode 100644 app/components/malio/date/composables/useCalendarView.ts create mode 100644 app/components/malio/date/composables/useMonthMatrix.test.ts create mode 100644 app/components/malio/date/composables/useMonthMatrix.ts create mode 100644 app/components/malio/date/internal/CalendarField.vue create mode 100644 app/components/malio/date/internal/CalendarHeader.vue create mode 100644 app/components/malio/date/internal/MonthGrid.vue create mode 100644 app/components/malio/date/internal/MonthPicker.vue create mode 100644 app/story/date/datePicker.story.vue create mode 100644 app/story/date/dateRange.story.vue create mode 100644 app/story/date/dateTime.story.vue create mode 100644 app/story/date/dateWeek.story.vue create mode 100644 docs/superpowers/plans/2026-05-20-datepicker.md create mode 100644 docs/superpowers/plans/2026-05-20-daterange.md create mode 100644 docs/superpowers/plans/2026-05-20-dateweek.md create mode 100644 docs/superpowers/plans/2026-05-22-datetime.md create mode 100644 docs/superpowers/specs/2026-05-19-datepicker-design.md create mode 100644 docs/superpowers/specs/2026-05-20-daterange-design.md create mode 100644 docs/superpowers/specs/2026-05-20-dateweek-design.md create mode 100644 docs/superpowers/specs/2026-05-22-datetime-design.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8aaa994..278a4af 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,9 @@ "Bash(mv buttonIcon.story.vue button/)", "Bash(mv inputText.story.vue inputAmount.story.vue inputNumber.story.vue inputPassword.story.vue inputTextArea.story.vue inputUpload.story.vue input/)", "Bash(mv InputSelect.story.vue selectCheckbox.story.vue select/)", - "Bash(mv inputCheckbox.story.vue checkbox/)" + "Bash(mv inputCheckbox.story.vue checkbox/)", + "Bash(npx eslint *)", + "Bash(echo \"LINT EXIT: $?\")" ] } } diff --git a/.claude/skills/creating-malio-component/SKILL.md b/.claude/skills/creating-malio-component/SKILL.md index 1e6695d..4fd9a17 100644 --- a/.claude/skills/creating-malio-component/SKILL.md +++ b/.claude/skills/creating-malio-component/SKILL.md @@ -108,9 +108,9 @@ npm run lint # Pas d'erreurs ### 5. Créer la page playground -**Fichier :** `.playground/pages/composant/.vue` (camelCase) +**Fichier :** `.playground/pages/composant//.vue` (camelCase, dans le sous-dossier de catégorie) -La page est auto-détectée par `index.vue` via `import.meta.glob`. Inclure des variantes représentatives dans une grille : +La page devient automatiquement une route Nuxt (`/composant//`) et hérite du layout `default` (qui affiche la `MalioSidebar`). **Ajouter ensuite le lien dans la nav centralisée** `.playground/playground.nav.ts` : insérer un `{label, to}` dans la section appropriée (ou créer une nouvelle section), où `to` = `/composant//`. Inclure des variantes représentatives dans une grille : ```html
@@ -216,7 +216,7 @@ Cette section est alimentée au fur et à mesure des retours utilisateur et des |--------|----------| | Stub IconifyIcon ne fonctionne pas dans les tests | Utiliser `findComponent(IconifyIcon)` avec l'import réel pour tester les props | | Oubli de `inheritAttrs: false` | Toujours dans `defineOptions` — sinon les attrs se dupliquent | -| Page playground non détectée | Vérifier le nom du fichier en camelCase dans `.playground/pages/composant/` | +| Composant absent de la sidebar du playground | Ajouter son entrée `{label, to}` dans `.playground/playground.nav.ts` (la page n'est plus auto-découverte) | | Padding input pas ajusté avec icône | Ajouter `!pr-10` (ou équivalent) quand une icône est présente à droite | | Story sans initial state | Toujours initialiser les `ref` avec des valeurs pour que les variantes soient visibles dès le chargement | | CHANGELOG oublié | Toujours ajouter la ligne dans `### Added` avant de commit | diff --git a/.playground/pages/composant/date/date.vue b/.playground/pages/composant/date/date.vue new file mode 100644 index 0000000..320a5b2 --- /dev/null +++ b/.playground/pages/composant/date/date.vue @@ -0,0 +1,68 @@ + + + diff --git a/.playground/pages/composant/date/dateRange.vue b/.playground/pages/composant/date/dateRange.vue new file mode 100644 index 0000000..4b0a553 --- /dev/null +++ b/.playground/pages/composant/date/dateRange.vue @@ -0,0 +1,72 @@ + + + diff --git a/.playground/pages/composant/date/dateWeek.vue b/.playground/pages/composant/date/dateWeek.vue new file mode 100644 index 0000000..71ab45e --- /dev/null +++ b/.playground/pages/composant/date/dateWeek.vue @@ -0,0 +1,68 @@ + + + diff --git a/.playground/pages/composant/date/datetime.vue b/.playground/pages/composant/date/datetime.vue new file mode 100644 index 0000000..f7d0938 --- /dev/null +++ b/.playground/pages/composant/date/datetime.vue @@ -0,0 +1,68 @@ + + + diff --git a/.playground/pages/composant/form/client.vue b/.playground/pages/composant/form/client.vue index ac6131c..e2887a1 100644 --- a/.playground/pages/composant/form/client.vue +++ b/.playground/pages/composant/form/client.vue @@ -78,7 +78,10 @@
- + @@ -158,6 +161,7 @@ diff --git a/app/components/malio/date/DateRange.test.ts b/app/components/malio/date/DateRange.test.ts new file mode 100644 index 0000000..b920377 --- /dev/null +++ b/app/components/malio/date/DateRange.test.ts @@ -0,0 +1,155 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import DateRange from './DateRange.vue' + +type RangeValue = {start: string; end: string} +type DateRangeProps = { + modelValue?: RangeValue | null + label?: string + disabled?: boolean + readonly?: boolean + error?: string + min?: string + max?: string + clearable?: boolean +} + +const DateRangeForTest = DateRange as DefineComponent +const mountRange = (props: DateRangeProps = {}) => + mount(DateRangeForTest, {props, attachTo: document.body}) + +const openAndClickDays = async (wrapper: ReturnType, isos: string[]) => { + await wrapper.get('[data-test="date-input"]').trigger('click') + for (const iso of isos) { + await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click') + } +} + +describe('MalioDateRange', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + it('renders the label and calendar icon', () => { + const wrapper = mountRange({label: 'Période'}) + expect(wrapper.get('label').text()).toBe('Période') + expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) + }) + + it('displays the formatted range when modelValue is set', () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('19/05/2026 - 25/05/2026') + }) + + it('shows an empty field without a value', () => { + const wrapper = mountRange() + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('') + }) + + it('opens on the start month when a range is set', async () => { + const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025') + }) + + it('does not emit on the first click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19']) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) + }) + + it('emits the range and closes on the second click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}]) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('auto-inverts when the second click is before the first', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}]) + }) + + it('allows a single-day range', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}]) + }) + + it('restarts a new range on the third click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25']) + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click') + expect(wrapper.emitted('update:modelValue')).toHaveLength(1) + await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}]) + }) + + it('previews the range on hover while selecting', async () => { + const wrapper = mountRange() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range') + }) + + it('does not preview before selecting', async () => { + const wrapper = mountRange() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('none') + }) + + it('marks start, end and in-range roles for a committed range', async () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range') + }) + + it('cancels an in-progress selection on outside click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19']) + document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none') + }) + + it('emits null on clear', async () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) + }) + + it('disables days outside min/max', async () => { + const wrapper = mountRange({min: '2026-05-10', max: '2026-05-20'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]') + expect((outside.element as HTMLButtonElement).disabled).toBe(true) + await outside.trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('sets aria-invalid on error', () => { + const wrapper = mountRange({error: 'Période requise'}) + expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true') + expect(wrapper.text()).toContain('Période requise') + }) + + it('does not open when disabled', async () => { + const wrapper = mountRange({disabled: true}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) +}) diff --git a/app/components/malio/date/DateRange.vue b/app/components/malio/date/DateRange.vue new file mode 100644 index 0000000..ecd488a --- /dev/null +++ b/app/components/malio/date/DateRange.vue @@ -0,0 +1,140 @@ + + + diff --git a/app/components/malio/date/DateTime.test.ts b/app/components/malio/date/DateTime.test.ts new file mode 100644 index 0000000..e78d29d --- /dev/null +++ b/app/components/malio/date/DateTime.test.ts @@ -0,0 +1,120 @@ +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' + +type DateTimeProps = { + id?: string + name?: string + label?: string + modelValue?: string | null + placeholder?: string + required?: boolean + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string + min?: string + max?: string + clearable?: boolean + inputClass?: string + labelClass?: string + groupClass?: string +} + +const DateTimeForTest = DateTime_ as DefineComponent +const mountDateTime = (props: DateTimeProps = {}) => + mount(DateTimeForTest, {props, attachTo: document.body}) + +describe('MalioDateTime', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + describe('rendu', () => { + it('affiche le label et l\'icône calendrier', () => { + const wrapper = mountDateTime({label: 'Rendez-vous'}) + expect(wrapper.get('label').text()).toBe('Rendez-vous') + expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) + }) + + it('affiche la valeur formatée date + heure dans le champ', () => { + const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'}) + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('20/05/2026 14:30') + }) + }) + + describe('popover', () => { + it('ouvre la grille et l\'input 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) + }) + }) + + describe('sélection', () => { + it('émet le jour à 00:00 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']) + 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 + 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']) + }) + + 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') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00']) + }) + + it('initialise l\'input 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') + }) + }) + + describe('bornes min/max', () => { + it('désactive les jours hors bornes (datetime borné sur la date)', async () => { + const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]') + expect((outside.element as HTMLButtonElement).disabled).toBe(true) + }) + }) + + describe('effacement', () => { + it('émet null au clic sur la croix', async () => { + const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) + }) + }) + + describe('accessibilité', () => { + it('positionne aria-invalid et describedby sur erreur', () => { + const wrapper = mountDateTime({error: 'Date requise'}) + const input = wrapper.get('[data-test="date-input"]') + expect(input.attributes('aria-invalid')).toBe('true') + expect(input.attributes('aria-describedby')).toBeTruthy() + expect(wrapper.text()).toContain('Date requise') + }) + }) +}) diff --git a/app/components/malio/date/DateTime.vue b/app/components/malio/date/DateTime.vue new file mode 100644 index 0000000..8cf7c25 --- /dev/null +++ b/app/components/malio/date/DateTime.vue @@ -0,0 +1,134 @@ + + + diff --git a/app/components/malio/date/DateWeek.test.ts b/app/components/malio/date/DateWeek.test.ts new file mode 100644 index 0000000..613c749 --- /dev/null +++ b/app/components/malio/date/DateWeek.test.ts @@ -0,0 +1,122 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import DateWeek from './DateWeek.vue' + +type DateWeekProps = { + modelValue?: string | null + label?: string + disabled?: boolean + readonly?: boolean + error?: string + min?: string + max?: string +} + +const DateWeekForTest = DateWeek as DefineComponent +const mountWeek = (props: DateWeekProps = {}) => + mount(DateWeekForTest, {props, attachTo: document.body}) + +describe('MalioDateWeek', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + it('renders the label and calendar icon', () => { + const wrapper = mountWeek({label: 'Semaine'}) + expect(wrapper.get('label').text()).toBe('Semaine') + expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) + }) + + it('displays the formatted week when modelValue is set', () => { + const wrapper = mountWeek({modelValue: '2026-W21'}) + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)') + }) + + it('shows an empty field without a value', () => { + const wrapper = mountWeek() + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('') + }) + + it('opens on the month of the selected week', async () => { + const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29 + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025') + }) + + it('selects the week when a day is clicked', async () => { + const wrapper = mountWeek() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21']) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('selects the week when the week number is clicked', async () => { + const wrapper = mountWeek() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21']) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('previews the whole week on day hover', async () => { + const wrapper = mountWeek() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range') + }) + + it('previews the whole week on week-number hover', async () => { + const wrapper = mountWeek() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range') + }) + + it('marks the committed week number', async () => { + const wrapper = mountWeek({modelValue: '2026-W21'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start') + }) + + it('emits null on clear', async () => { + const wrapper = mountWeek({modelValue: '2026-W21'}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) + }) + + it('disables a week fully outside min/max', async () => { + const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]') + expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true) + const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]') + expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('does not open when disabled', async () => { + const wrapper = mountWeek({disabled: true}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('does not open when readonly', async () => { + const wrapper = mountWeek({readonly: true, modelValue: '2026-W21'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('sets aria-invalid on error', () => { + const wrapper = mountWeek({error: 'Semaine requise'}) + expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true') + expect(wrapper.text()).toContain('Semaine requise') + }) +}) diff --git a/app/components/malio/date/DateWeek.vue b/app/components/malio/date/DateWeek.vue new file mode 100644 index 0000000..4230e08 --- /dev/null +++ b/app/components/malio/date/DateWeek.vue @@ -0,0 +1,123 @@ + + + diff --git a/app/components/malio/date/composables/dateFormat.test.ts b/app/components/malio/date/composables/dateFormat.test.ts new file mode 100644 index 0000000..744c0d3 --- /dev/null +++ b/app/components/malio/date/composables/dateFormat.test.ts @@ -0,0 +1,62 @@ +import {describe, expect, it} from 'vitest' +import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat' + +describe('dateFormat', () => { + describe('isValidIso', () => { + it('accepts a real ISO date', () => { + expect(isValidIso('2026-05-19')).toBe(true) + }) + it('rejects a malformed string', () => { + expect(isValidIso('19/05/2026')).toBe(false) + expect(isValidIso('2026-5-9')).toBe(false) + expect(isValidIso('')).toBe(false) + }) + it('rejects an impossible date', () => { + expect(isValidIso('2026-02-30')).toBe(false) + expect(isValidIso('2026-13-01')).toBe(false) + }) + it('accepts Feb 29 on a leap year and rejects it otherwise', () => { + expect(isValidIso('2024-02-29')).toBe(true) + expect(isValidIso('2026-02-29')).toBe(false) + }) + }) + + describe('formatIsoToDisplay', () => { + it('formats ISO to DD/MM/YYYY', () => { + expect(formatIsoToDisplay('2026-05-19')).toBe('19/05/2026') + }) + it('returns empty string for null or invalid input', () => { + expect(formatIsoToDisplay(null)).toBe('') + expect(formatIsoToDisplay('nope')).toBe('') + }) + }) + + describe('parseDisplayToIso', () => { + it('parses DD/MM/YYYY to ISO', () => { + expect(parseDisplayToIso('19/05/2026')).toBe('2026-05-19') + }) + it('returns null for malformed or impossible input', () => { + expect(parseDisplayToIso('2026-05-19')).toBeNull() + expect(parseDisplayToIso('31/02/2026')).toBeNull() + expect(parseDisplayToIso('')).toBeNull() + }) + }) + + describe('isDateInRange', () => { + it('returns true when no bounds are given', () => { + expect(isDateInRange('2026-05-19')).toBe(true) + }) + it('respects the min bound (inclusive)', () => { + expect(isDateInRange('2026-05-19', '2026-05-19')).toBe(true) + expect(isDateInRange('2026-05-18', '2026-05-19')).toBe(false) + }) + it('respects the max bound (inclusive)', () => { + expect(isDateInRange('2026-05-19', undefined, '2026-05-19')).toBe(true) + expect(isDateInRange('2026-05-20', undefined, '2026-05-19')).toBe(false) + }) + it('respects both bounds', () => { + expect(isDateInRange('2026-05-15', '2026-05-10', '2026-05-20')).toBe(true) + expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false) + }) + }) +}) diff --git a/app/components/malio/date/composables/dateFormat.ts b/app/components/malio/date/composables/dateFormat.ts new file mode 100644 index 0000000..fc0bf1d --- /dev/null +++ b/app/components/malio/date/composables/dateFormat.ts @@ -0,0 +1,26 @@ +export function isValidIso(iso: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false + const [y, m, d] = iso.split('-').map(Number) + const date = new Date(y, m - 1, d) + return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d +} + +export function formatIsoToDisplay(iso: string | null): string { + if (!iso || !isValidIso(iso)) return '' + const [y, m, d] = iso.split('-') + return `${d}/${m}/${y}` +} + +export function parseDisplayToIso(display: string): string | null { + const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(display.trim()) + if (!match) return null + const [, dd, mm, yyyy] = match + const iso = `${yyyy}-${mm}-${dd}` + return isValidIso(iso) ? iso : null +} + +export function isDateInRange(iso: string, min?: string, max?: string): boolean { + if (min && iso < min) return false + if (max && iso > max) return false + return true +} diff --git a/app/components/malio/date/composables/dateRange.test.ts b/app/components/malio/date/composables/dateRange.test.ts new file mode 100644 index 0000000..d6e5ab7 --- /dev/null +++ b/app/components/malio/date/composables/dateRange.test.ts @@ -0,0 +1,57 @@ +import {describe, expect, it} from 'vitest' +import {dayRangeRole, normalizeRange, resolveRangeBounds} from './dateRange' + +describe('dateRange', () => { + describe('normalizeRange', () => { + it('keeps an already ordered pair', () => { + expect(normalizeRange('2026-05-19', '2026-05-25')).toEqual({start: '2026-05-19', end: '2026-05-25'}) + }) + it('swaps a reversed pair', () => { + expect(normalizeRange('2026-05-25', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-25'}) + }) + it('handles an equal pair', () => { + expect(normalizeRange('2026-05-19', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-19'}) + }) + }) + + describe('resolveRangeBounds', () => { + it('returns null without a start', () => { + expect(resolveRangeBounds(null, null, null)).toBeNull() + }) + it('returns a single-point range when only start is set', () => { + expect(resolveRangeBounds('2026-05-19', null, null)).toEqual({lo: '2026-05-19', hi: '2026-05-19'}) + }) + it('orders start and committed end', () => { + expect(resolveRangeBounds('2026-05-19', '2026-05-25', null)).toEqual({lo: '2026-05-19', hi: '2026-05-25'}) + }) + it('uses preview when end is not set', () => { + expect(resolveRangeBounds('2026-05-19', null, '2026-05-22')).toEqual({lo: '2026-05-19', hi: '2026-05-22'}) + }) + it('inverts when preview is before start', () => { + expect(resolveRangeBounds('2026-05-19', null, '2026-05-10')).toEqual({lo: '2026-05-10', hi: '2026-05-19'}) + }) + it('prioritises committed end over preview', () => { + expect(resolveRangeBounds('2026-05-19', '2026-05-25', '2026-05-30')).toEqual({lo: '2026-05-19', hi: '2026-05-25'}) + }) + }) + + describe('dayRangeRole', () => { + const bounds = {lo: '2026-05-19', hi: '2026-05-25'} + it('returns none without bounds', () => { + expect(dayRangeRole('2026-05-20', null)).toBe('none') + }) + it('returns single when lo === hi and matches', () => { + expect(dayRangeRole('2026-05-19', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('single') + expect(dayRangeRole('2026-05-20', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('none') + }) + it('returns start, end and in-range', () => { + expect(dayRangeRole('2026-05-19', bounds)).toBe('start') + expect(dayRangeRole('2026-05-25', bounds)).toBe('end') + expect(dayRangeRole('2026-05-22', bounds)).toBe('in-range') + }) + it('returns none outside the bounds', () => { + expect(dayRangeRole('2026-05-10', bounds)).toBe('none') + expect(dayRangeRole('2026-05-30', bounds)).toBe('none') + }) + }) +}) diff --git a/app/components/malio/date/composables/dateRange.ts b/app/components/malio/date/composables/dateRange.ts new file mode 100644 index 0000000..5dd62b5 --- /dev/null +++ b/app/components/malio/date/composables/dateRange.ts @@ -0,0 +1,31 @@ +export type DateRangeValue = {start: string; end: string} + +export function normalizeRange(a: string, b: string): DateRangeValue { + return a <= b ? {start: a, end: b} : {start: b, end: a} +} + +export function resolveRangeBounds( + start: string | null, + end: string | null, + preview: string | null, +): {lo: string; hi: string} | null { + if (!start) return null + const other = end ?? preview + if (!other) return {lo: start, hi: start} + return start <= other ? {lo: start, hi: other} : {lo: other, hi: start} +} + +export type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range' + +export function dayRangeRole( + iso: string, + bounds: {lo: string; hi: string} | null, +): DayRangeRole { + if (!bounds) return 'none' + const {lo, hi} = bounds + if (lo === hi) return iso === lo ? 'single' : 'none' + if (iso === lo) return 'start' + if (iso === hi) return 'end' + if (iso > lo && iso < hi) return 'in-range' + return 'none' +} diff --git a/app/components/malio/date/composables/dateWeek.test.ts b/app/components/malio/date/composables/dateWeek.test.ts new file mode 100644 index 0000000..0d52e5b --- /dev/null +++ b/app/components/malio/date/composables/dateWeek.test.ts @@ -0,0 +1,74 @@ +import {describe, expect, it} from 'vitest' +import { + formatWeekDisplay, + isValidIsoWeek, + isoWeekToMonday, + mondayOf, + sundayOf, + toIsoWeek, +} from './dateWeek' + +describe('dateWeek', () => { + describe('mondayOf / sundayOf', () => { + it('returns Monday and Sunday of a midweek date', () => { + expect(mondayOf('2026-05-20')).toBe('2026-05-18') // mercredi + expect(sundayOf('2026-05-20')).toBe('2026-05-24') + }) + it('keeps Monday on a Monday', () => { + expect(mondayOf('2026-05-18')).toBe('2026-05-18') + }) + it('returns the preceding Monday for a Sunday', () => { + expect(mondayOf('2026-05-24')).toBe('2026-05-18') + }) + }) + + describe('toIsoWeek', () => { + it('returns the ISO week of a date', () => { + expect(toIsoWeek('2026-05-20')).toBe('2026-W21') + }) + it('handles year boundaries', () => { + expect(toIsoWeek('2026-01-01')).toBe('2026-W01') + expect(toIsoWeek('2025-12-31')).toBe('2026-W01') + expect(toIsoWeek('2027-01-01')).toBe('2026-W53') + }) + }) + + describe('isoWeekToMonday', () => { + it('returns the Monday of a week string', () => { + expect(isoWeekToMonday('2026-W21')).toBe('2026-05-18') + }) + it('round-trips with toIsoWeek', () => { + for (const w of ['2026-W01', '2026-W21', '2026-W53', '2024-W09']) { + const monday = isoWeekToMonday(w) + expect(monday).not.toBeNull() + expect(toIsoWeek(monday as string)).toBe(w) + } + }) + it('returns null for invalid input', () => { + expect(isoWeekToMonday('2026-21')).toBeNull() + expect(isoWeekToMonday('2026-W00')).toBeNull() + expect(isoWeekToMonday('2026-W54')).toBeNull() + expect(isoWeekToMonday('2025-W53')).toBeNull() // 2025 n'a que 52 semaines ISO + }) + }) + + describe('isValidIsoWeek', () => { + it('accepts a real ISO week', () => { + expect(isValidIsoWeek('2026-W21')).toBe(true) + }) + it('rejects malformed or impossible weeks', () => { + expect(isValidIsoWeek('2026-21')).toBe(false) + expect(isValidIsoWeek('2026-W00')).toBe(false) + expect(isValidIsoWeek('2026-W54')).toBe(false) + }) + }) + + describe('formatWeekDisplay', () => { + it('formats a week as a human label', () => { + expect(formatWeekDisplay('2026-W21')).toBe('Semaine 21 (18/05 → 24/05/2026)') + }) + it('returns empty string for invalid input', () => { + expect(formatWeekDisplay('2026-W54')).toBe('') + }) + }) +}) diff --git a/app/components/malio/date/composables/dateWeek.ts b/app/components/malio/date/composables/dateWeek.ts new file mode 100644 index 0000000..ba17ed0 --- /dev/null +++ b/app/components/malio/date/composables/dateWeek.ts @@ -0,0 +1,67 @@ +import {formatIsoToDisplay} from './dateFormat' + +const parseUtc = (iso: string): Date => { + const [y, m, d] = iso.split('-').map(Number) + return new Date(Date.UTC(y, m - 1, d)) +} + +const toIso = (d: Date): string => { + const y = d.getUTCFullYear() + const m = String(d.getUTCMonth() + 1).padStart(2, '0') + const day = String(d.getUTCDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +export function mondayOf(iso: string): string { + const d = parseUtc(iso) + const dayNum = d.getUTCDay() || 7 // dimanche = 7 + d.setUTCDate(d.getUTCDate() - (dayNum - 1)) + return toIso(d) +} + +export function sundayOf(iso: string): string { + const d = parseUtc(mondayOf(iso)) + d.setUTCDate(d.getUTCDate() + 6) + return toIso(d) +} + +export function toIsoWeek(iso: string): string { + const d = parseUtc(iso) + const dayNum = d.getUTCDay() || 7 + d.setUTCDate(d.getUTCDate() + 4 - dayNum) // jeudi de la semaine + const isoYear = d.getUTCFullYear() + const yearStart = new Date(Date.UTC(isoYear, 0, 1)) + const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + return `${isoYear}-W${String(week).padStart(2, '0')}` +} + +export function isoWeekToMonday(week: string): string | null { + const m = /^(\d{4})-W(\d{2})$/.exec(week) + if (!m) return null + const year = Number(m[1]) + const w = Number(m[2]) + if (w < 1 || w > 53) return null + // Lundi de la semaine 1 = lundi de la semaine contenant le 4 janvier + const jan4 = new Date(Date.UTC(year, 0, 4)) + const jan4Day = jan4.getUTCDay() || 7 + const monday = new Date(jan4) + monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1) + (w - 1) * 7) + const iso = toIso(monday) + // Garde-fou : la semaine 53 n'existe pas pour toutes les années + if (toIsoWeek(iso) !== week) return null + return iso +} + +export function isValidIsoWeek(week: string): boolean { + return isoWeekToMonday(week) !== null +} + +export function formatWeekDisplay(week: string): string { + const monday = isoWeekToMonday(week) + if (!monday) return '' + const sunday = sundayOf(monday) + const w = Number(week.slice(6)) + const startDdMm = formatIsoToDisplay(monday).slice(0, 5) // "18/05" + const endFull = formatIsoToDisplay(sunday) // "24/05/2026" + return `Semaine ${w} (${startDdMm} → ${endFull})` +} diff --git a/app/components/malio/date/composables/datetimeFormat.test.ts b/app/components/malio/date/composables/datetimeFormat.test.ts new file mode 100644 index 0000000..70e2b08 --- /dev/null +++ b/app/components/malio/date/composables/datetimeFormat.test.ts @@ -0,0 +1,61 @@ +import {describe, expect, it} from 'vitest' +import { + composeDateTime, + formatIsoDateTimeToDisplay, + isValidIsoDateTime, + splitDateTime, +} from './datetimeFormat' + +describe('datetimeFormat', () => { + describe('isValidIsoDateTime', () => { + it('accepte un datetime ISO complet valide', () => { + expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true) + expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true) + expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true) + }) + + it('rejette une date seule, des composants invalides ou une chaîne vide', () => { + expect(isValidIsoDateTime('2026-05-20')).toBe(false) + expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false) + expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false) + expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false) + expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false) + expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false) + expect(isValidIsoDateTime('')).toBe(false) + }) + }) + + describe('formatIsoDateTimeToDisplay', () => { + it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => { + expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30') + }) + + it('renvoie une chaîne vide pour nul ou invalide', () => { + expect(formatIsoDateTimeToDisplay(null)).toBe('') + expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('') + expect(formatIsoDateTimeToDisplay('nope')).toBe('') + }) + }) + + describe('splitDateTime', () => { + it('découpe un datetime valide', () => { + expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'}) + }) + + it('renvoie date null et time vide pour nul, date seule ou invalide', () => { + expect(splitDateTime(null)).toEqual({date: null, time: ''}) + expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''}) + expect(splitDateTime('nope')).toEqual({date: null, time: ''}) + }) + }) + + describe('composeDateTime', () => { + it('recompose un datetime ISO avec secondes à 00', () => { + expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00') + }) + + it('utilise 00:00 quand l\'heure est vide', () => { + expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00') + }) + }) +}) diff --git a/app/components/malio/date/composables/datetimeFormat.ts b/app/components/malio/date/composables/datetimeFormat.ts new file mode 100644 index 0000000..6c6bd6c --- /dev/null +++ b/app/components/malio/date/composables/datetimeFormat.ts @@ -0,0 +1,33 @@ +import {isValidIso} from './dateFormat' + +const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/ + +export function isValidIsoDateTime(s: string): boolean { + const m = DATETIME_RE.exec(s) + if (!m) return false + const [, date, hh, mm, ss] = m + if (!isValidIso(date)) return false + const h = Number(hh) + const min = Number(mm) + const sec = Number(ss) + return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59 +} + +export function formatIsoDateTimeToDisplay(s: string | null): string { + if (!s || !isValidIsoDateTime(s)) return '' + const [date, time] = s.split('T') + const [y, mo, d] = date.split('-') + const [hh, mm] = time.split(':') + return `${d}/${mo}/${y} ${hh}:${mm}` +} + +export function splitDateTime(s: string | null): {date: string | null; time: string} { + if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''} + const [date, time] = s.split('T') + return {date, time: time.slice(0, 5)} +} + +export function composeDateTime(date: string, time: string): string { + const t = time || '00:00' + return `${date}T${t}:00` +} diff --git a/app/components/malio/date/composables/useCalendarPopover.test.ts b/app/components/malio/date/composables/useCalendarPopover.test.ts new file mode 100644 index 0000000..206b5f4 --- /dev/null +++ b/app/components/malio/date/composables/useCalendarPopover.test.ts @@ -0,0 +1,64 @@ +import {describe, expect, it} from 'vitest' +import {defineComponent, h, ref} from 'vue' +import {mount} from '@vue/test-utils' +import {useCalendarPopover} from './useCalendarPopover' + +const mountHost = () => { + const api: ReturnType = {} as never + const Host = defineComponent({ + setup() { + const root = ref(null) + Object.assign(api, useCalendarPopover(root)) + return () => h('div', {ref: root}, 'host') + }, + }) + const wrapper = mount(Host, {attachTo: document.body}) + return {wrapper, api} +} + +describe('useCalendarPopover', () => { + it('starts closed in days view', () => { + const {api} = mountHost() + expect(api.isOpen.value).toBe(false) + expect(api.viewMode.value).toBe('days') + }) + + it('open() opens in days view', () => { + const {api} = mountHost() + api.open() + expect(api.isOpen.value).toBe(true) + expect(api.viewMode.value).toBe('days') + }) + + it('toggleView() switches between days and months', () => { + const {api} = mountHost() + api.open() + api.toggleView() + expect(api.viewMode.value).toBe('months') + api.toggleView() + expect(api.viewMode.value).toBe('days') + }) + + it('close() resets isOpen and viewMode', () => { + const {api} = mountHost() + api.open() + api.toggleView() + api.close() + expect(api.isOpen.value).toBe(false) + expect(api.viewMode.value).toBe('days') + }) + + it('closes on outside mousedown', () => { + const {api} = mountHost() + api.open() + document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})) + expect(api.isOpen.value).toBe(false) + }) + + it('stays open on inside mousedown', () => { + const {wrapper, api} = mountHost() + api.open() + wrapper.element.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})) + expect(api.isOpen.value).toBe(true) + }) +}) diff --git a/app/components/malio/date/composables/useCalendarPopover.ts b/app/components/malio/date/composables/useCalendarPopover.ts new file mode 100644 index 0000000..db179cc --- /dev/null +++ b/app/components/malio/date/composables/useCalendarPopover.ts @@ -0,0 +1,28 @@ +import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue' + +export function useCalendarPopover(rootRef: Ref) { + const isOpen = ref(false) + const viewMode = ref<'days' | 'months'>('days') + + const open = () => { + isOpen.value = true + viewMode.value = 'days' + } + const close = () => { + isOpen.value = false + viewMode.value = 'days' + } + const toggleView = () => { + viewMode.value = viewMode.value === 'days' ? 'months' : 'days' + } + + const onMouseDown = (event: MouseEvent) => { + if (!isOpen.value || !rootRef.value) return + if (!rootRef.value.contains(event.target as Node)) close() + } + + onMounted(() => document.addEventListener('mousedown', onMouseDown)) + onBeforeUnmount(() => document.removeEventListener('mousedown', onMouseDown)) + + return {isOpen, viewMode, open, close, toggleView} +} diff --git a/app/components/malio/date/composables/useCalendarView.test.ts b/app/components/malio/date/composables/useCalendarView.test.ts new file mode 100644 index 0000000..1aa0d99 --- /dev/null +++ b/app/components/malio/date/composables/useCalendarView.test.ts @@ -0,0 +1,68 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {ref} from 'vue' +import {useCalendarView} from './useCalendarView' + +describe('useCalendarView', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + it('initialises to the current month and year', () => { + const {currentMonth, currentYear} = useCalendarView(ref('days')) + expect(currentMonth.value).toBe(4) + expect(currentYear.value).toBe(2026) + }) + + it('goToNext advances the month in days view', () => { + const {currentMonth, goToNext} = useCalendarView(ref('days')) + goToNext() + expect(currentMonth.value).toBe(5) + }) + + it('rolls December to January and bumps the year', () => { + const {currentMonth, currentYear, goToNext} = useCalendarView(ref('days')) + currentMonth.value = 11 + goToNext() + expect(currentMonth.value).toBe(0) + expect(currentYear.value).toBe(2027) + }) + + it('rolls January to December backwards', () => { + const {currentMonth, currentYear, goToPrev} = useCalendarView(ref('days')) + currentMonth.value = 0 + goToPrev() + expect(currentMonth.value).toBe(11) + expect(currentYear.value).toBe(2025) + }) + + it('navigates the year in months view', () => { + const {currentYear, goToNext, goToPrev} = useCalendarView(ref('months')) + goToNext() + expect(currentYear.value).toBe(2027) + goToPrev() + expect(currentYear.value).toBe(2026) + }) + + it('selectMonth sets the current month', () => { + const {currentMonth, selectMonth} = useCalendarView(ref('days')) + selectMonth(0) + expect(currentMonth.value).toBe(0) + }) + + it('syncToIso sets month/year from a valid ISO', () => { + const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days')) + syncToIso('2025-12-25') + expect(currentMonth.value).toBe(11) + expect(currentYear.value).toBe(2025) + }) + + it('syncToIso falls back to today for null/invalid', () => { + const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days')) + syncToIso('2025-12-25') + syncToIso(null) + expect(currentMonth.value).toBe(4) + expect(currentYear.value).toBe(2026) + }) +}) diff --git a/app/components/malio/date/composables/useCalendarView.ts b/app/components/malio/date/composables/useCalendarView.ts new file mode 100644 index 0000000..f0097b4 --- /dev/null +++ b/app/components/malio/date/composables/useCalendarView.ts @@ -0,0 +1,51 @@ +import {ref, type Ref} from 'vue' +import {isValidIso} from './dateFormat' + +export function useCalendarView(viewMode: Ref<'days' | 'months'>) { + const today = new Date() + const currentMonth = ref(today.getMonth()) + const currentYear = ref(today.getFullYear()) + + const goToPrev = () => { + if (viewMode.value === 'months') { + currentYear.value -= 1 + return + } + if (currentMonth.value === 0) { + currentMonth.value = 11 + currentYear.value -= 1 + } else { + currentMonth.value -= 1 + } + } + + const goToNext = () => { + if (viewMode.value === 'months') { + currentYear.value += 1 + return + } + if (currentMonth.value === 11) { + currentMonth.value = 0 + currentYear.value += 1 + } else { + currentMonth.value += 1 + } + } + + const selectMonth = (m: number) => { + currentMonth.value = m + } + + const syncToIso = (iso: string | null) => { + if (iso && isValidIso(iso)) { + currentMonth.value = Number(iso.slice(5, 7)) - 1 + currentYear.value = Number(iso.slice(0, 4)) + } else { + const now = new Date() + currentMonth.value = now.getMonth() + currentYear.value = now.getFullYear() + } + } + + return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} +} diff --git a/app/components/malio/date/composables/useMonthMatrix.test.ts b/app/components/malio/date/composables/useMonthMatrix.test.ts new file mode 100644 index 0000000..edb55a9 --- /dev/null +++ b/app/components/malio/date/composables/useMonthMatrix.test.ts @@ -0,0 +1,69 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {ref} from 'vue' +import {useMonthMatrix} from './useMonthMatrix' + +describe('useMonthMatrix', () => { + it('always produces 6 weeks of 7 days', () => { + const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai 2026 + expect(weeks.value).toHaveLength(6) + weeks.value.forEach(week => expect(week.days).toHaveLength(7)) + }) + + it('starts every week on a Monday', () => { + const {weeks} = useMonthMatrix(ref(4), ref(2026)) + weeks.value.forEach(week => { + const first = new Date(`${week.days[0].isoDate}T00:00:00`) + expect(first.getDay()).toBe(1) // 1 = lundi + }) + }) + + it('flags exactly the days of the current month', () => { + const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai = 31 jours + const currentMonthDays = weeks.value + .flatMap(w => w.days) + .filter(d => d.isCurrentMonth) + expect(currentMonthDays).toHaveLength(31) + expect(currentMonthDays.every(d => d.isoDate.startsWith('2026-05'))).toBe(true) + }) + + it('handles leap year February (29 days)', () => { + const {weeks} = useMonthMatrix(ref(1), ref(2024)) // février 2024 + const days = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth) + expect(days).toHaveLength(29) + }) + + it('assigns ISO week 1 to the week containing Jan 4th', () => { + const {weeks} = useMonthMatrix(ref(0), ref(2026)) // janvier 2026 + const weekWithJan4 = weeks.value.find(w => + w.days.some(d => d.isoDate === '2026-01-04'), + ) + expect(weekWithJan4?.weekNumber).toBe(1) + }) + + it('reacts to month/year changes', () => { + const month = ref(4) + const year = ref(2026) + const {weeks} = useMonthMatrix(month, year) + const mayCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length + month.value = 1 // février + year.value = 2024 + const febCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length + expect(mayCount).toBe(31) + expect(febCount).toBe(29) + }) + + describe('isToday', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + it('flags only today', () => { + const {weeks} = useMonthMatrix(ref(4), ref(2026)) + const todays = weeks.value.flatMap(w => w.days).filter(d => d.isToday) + expect(todays).toHaveLength(1) + expect(todays[0].isoDate).toBe('2026-05-19') + }) + }) +}) diff --git a/app/components/malio/date/composables/useMonthMatrix.ts b/app/components/malio/date/composables/useMonthMatrix.ts new file mode 100644 index 0000000..893c23b --- /dev/null +++ b/app/components/malio/date/composables/useMonthMatrix.ts @@ -0,0 +1,60 @@ +import {computed, type ComputedRef, type Ref} from 'vue' + +export type DayCell = { + isoDate: string + day: number + isCurrentMonth: boolean + isToday: boolean +} +export type WeekRow = { + weekNumber: number + days: DayCell[] +} + +const toIso = (d: Date): string => { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +const isoWeek = (d: Date): number => { + const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())) + const dayNum = target.getUTCDay() || 7 // dimanche = 7 + target.setUTCDate(target.getUTCDate() + 4 - dayNum) // jeudi de la semaine + const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1)) + return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) +} + +export function useMonthMatrix( + month: Ref, + year: Ref, +): {weeks: ComputedRef} { + const weeks = computed(() => { + const todayIso = toIso(new Date()) + const first = new Date(year.value, month.value, 1) + // recule jusqu'au lundi (getDay : 0 = dimanche) + const offset = (first.getDay() + 6) % 7 + const start = new Date(year.value, month.value, 1 - offset) + + const rows: WeekRow[] = [] + const cursor = new Date(start) + for (let w = 0; w < 6; w++) { + const days: DayCell[] = [] + for (let i = 0; i < 7; i++) { + const iso = toIso(cursor) + days.push({ + isoDate: iso, + day: cursor.getDate(), + isCurrentMonth: cursor.getMonth() === month.value, + isToday: iso === todayIso, + }) + cursor.setDate(cursor.getDate() + 1) + } + rows.push({weekNumber: isoWeek(new Date(`${days[0].isoDate}T00:00:00`)), days}) + } + return rows + }) + + return {weeks} +} diff --git a/app/components/malio/date/internal/CalendarField.vue b/app/components/malio/date/internal/CalendarField.vue new file mode 100644 index 0000000..15e9148 --- /dev/null +++ b/app/components/malio/date/internal/CalendarField.vue @@ -0,0 +1,239 @@ + + + + + diff --git a/app/components/malio/date/internal/CalendarHeader.vue b/app/components/malio/date/internal/CalendarHeader.vue new file mode 100644 index 0000000..be9109e --- /dev/null +++ b/app/components/malio/date/internal/CalendarHeader.vue @@ -0,0 +1,70 @@ + + + diff --git a/app/components/malio/date/internal/MonthGrid.vue b/app/components/malio/date/internal/MonthGrid.vue new file mode 100644 index 0000000..7a586c6 --- /dev/null +++ b/app/components/malio/date/internal/MonthGrid.vue @@ -0,0 +1,178 @@ + + + diff --git a/app/components/malio/date/internal/MonthPicker.vue b/app/components/malio/date/internal/MonthPicker.vue new file mode 100644 index 0000000..2b525a1 --- /dev/null +++ b/app/components/malio/date/internal/MonthPicker.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/story/date/datePicker.story.vue b/app/story/date/datePicker.story.vue new file mode 100644 index 0000000..5c6248f --- /dev/null +++ b/app/story/date/datePicker.story.vue @@ -0,0 +1,94 @@ + + + diff --git a/app/story/date/dateRange.story.vue b/app/story/date/dateRange.story.vue new file mode 100644 index 0000000..2d6dea3 --- /dev/null +++ b/app/story/date/dateRange.story.vue @@ -0,0 +1,77 @@ + + + diff --git a/app/story/date/dateTime.story.vue b/app/story/date/dateTime.story.vue new file mode 100644 index 0000000..a70f0c9 --- /dev/null +++ b/app/story/date/dateTime.story.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/story/date/dateWeek.story.vue b/app/story/date/dateWeek.story.vue new file mode 100644 index 0000000..0d1bf3b --- /dev/null +++ b/app/story/date/dateWeek.story.vue @@ -0,0 +1,75 @@ + + + diff --git a/docs/superpowers/plans/2026-05-20-datepicker.md b/docs/superpowers/plans/2026-05-20-datepicker.md new file mode 100644 index 0000000..aa925c2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-datepicker.md @@ -0,0 +1,1440 @@ +# MalioDate (Datepicker) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Composant `` : champ readonly + popover calendrier (vue jours + vue mois), valeur ISO `YYYY-MM-DD`, bornes `min`/`max`, effacement. + +**Architecture:** Composant public `Date.vue` orchestrant 3 sous-composants internes (`CalendarHeader`, `MonthGrid`, `MonthPicker`) et 3 modules colocalisés (`dateFormat`, `useMonthMatrix`, `useCalendarPopover`). On construit du bas vers le haut : modules purs d'abord (TDD strict), puis sous-composants, puis composant public. + +**Tech Stack:** Nuxt 4 layer, Vue 3 ` +``` + +- [ ] **Step 2 : Vérifier le lint** + +Run: `npm run lint` +Expected: aucune nouvelle erreur (les warnings préexistants sont tolérés) + +- [ ] **Step 3 : Commit** + +```bash +git add app/components/malio/date/internal/MonthGrid.vue +git commit -m "feat : grille mensuelle du datepicker (#MUI-33)" +``` + +--- + +## Task 5 : Sous-composant `CalendarHeader.vue` + +**Files:** +- Create: `app/components/malio/date/internal/CalendarHeader.vue` + +Props : `viewMode: 'days' | 'months'`, `currentMonth: number`, `currentYear: number`. Emits : `prev`, `next`, `toggle-view`. + +- [ ] **Step 1 : Implémenter le composant** — *utilisateur* (référence ci-dessous) + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier le lint** + +Run: `npm run lint` +Expected: aucune nouvelle erreur + +- [ ] **Step 3 : Commit** + +```bash +git add app/components/malio/date/internal/CalendarHeader.vue +git commit -m "feat : header de navigation du datepicker (#MUI-33)" +``` + +--- + +## Task 6 : Sous-composant `MonthPicker.vue` + +**Files:** +- Create: `app/components/malio/date/internal/MonthPicker.vue` + +Props : `selectedMonth?: number`. Emit : `select` (payload `number` 0-11). + +- [ ] **Step 1 : Implémenter le composant** — *utilisateur* (référence ci-dessous) + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier le lint** + +Run: `npm run lint` +Expected: aucune nouvelle erreur + +- [ ] **Step 3 : Commit** + +```bash +git add app/components/malio/date/internal/MonthPicker.vue +git commit -m "feat : sélecteur de mois du datepicker (#MUI-33)" +``` + +--- + +## Task 7 : Composant public `Date.vue` + tests d'intégration + +**Files:** +- Create: `app/components/malio/date/Date.vue` +- Test: `app/components/malio/date/Date.test.ts` + +- [ ] **Step 1 : Implémenter le composant** — *utilisateur* (référence ci-dessous) + +```vue + + + + + +``` + +- [ ] **Step 2 : Écrire les tests d'intégration (échouent au départ si lancés avant l'impl)** — *assistant* + +```ts +import {describe, expect, it, beforeEach, afterEach, vi} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import Date_ from './Date.vue' + +type DateProps = { + id?: string + name?: string + label?: string + modelValue?: string | null + placeholder?: string + required?: boolean + disabled?: boolean + readonly?: boolean + hint?: string + error?: string + success?: string + min?: string + max?: string + clearable?: boolean + inputClass?: string + labelClass?: string + groupClass?: string +} + +const DateForTest = Date_ as DefineComponent +const mountDate = (props: DateProps = {}) => mount(DateForTest, {props, attachTo: document.body}) + +describe('MalioDate', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + describe('rendu', () => { + it('renders the label and the calendar icon', () => { + const wrapper = mountDate({label: 'Date de naissance'}) + expect(wrapper.get('label').text()).toBe('Date de naissance') + expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) + }) + + it('displays the formatted value in the field', () => { + const wrapper = mountDate({modelValue: '2026-05-19'}) + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('19/05/2026') + }) + + it('does not show the popover initially', () => { + const wrapper = mountDate() + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + }) + + describe('popover', () => { + it('opens on field click', async () => { + const wrapper = mountDate() + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) + }) + + it('opens on the current month when there is no value', async () => { + const wrapper = mountDate() + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Mai 2026') + }) + + it('opens on the value month when a value is set', async () => { + const wrapper = mountDate({modelValue: '2025-12-25'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025') + }) + + it('closes on outside mousedown', async () => { + const wrapper = mountDate() + await wrapper.get('[data-test="date-input"]').trigger('click') + document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})) + await wrapper.vm.$nextTick() + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + }) + + describe('navigation jours', () => { + it('goes to the next month on the right chevron', async () => { + const wrapper = mountDate() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="header-next"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Juin 2026') + }) + + it('rolls December to January and bumps the year', async () => { + const wrapper = mountDate({modelValue: '2026-12-15'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="header-next"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2027') + }) + }) + + describe('sélection', () => { + it('emits the ISO date and closes on day click', async () => { + const wrapper = mountDate() + 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-19']) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + }) + + describe('bornes min/max', () => { + it('disables days outside the range', async () => { + const wrapper = mountDate({min: '2026-05-10', max: '2026-05-20'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]') + expect((outside.element as HTMLButtonElement).disabled).toBe(true) + await outside.trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + }) + + describe('vue mois', () => { + it('switches to month view on header toggle', async () => { + const wrapper = mountDate() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="header-toggle"]').trigger('click') + expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(true) + }) + + it('navigates the year with chevrons in month view', async () => { + const wrapper = mountDate() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="header-toggle"]').trigger('click') + await wrapper.get('[data-test="header-next"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('2027') + }) + + it('returns to day view on month click', async () => { + const wrapper = mountDate() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="header-toggle"]').trigger('click') + await wrapper.get('[data-test="month"][data-month="0"]').trigger('click') + expect(wrapper.find('[data-test="month-picker"]').exists()).toBe(false) + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Janvier 2026') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + }) + + describe('effacement', () => { + it('shows the clear button when there is a value', () => { + const wrapper = mountDate({modelValue: '2026-05-19'}) + expect(wrapper.find('[data-test="clear"]').exists()).toBe(true) + }) + + it('hides the clear button when empty', () => { + const wrapper = mountDate() + expect(wrapper.find('[data-test="clear"]').exists()).toBe(false) + }) + + it('emits null and does not open the popover on clear', async () => { + const wrapper = mountDate({modelValue: '2026-05-19'}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + }) + + describe('états', () => { + it('does not open when disabled', async () => { + const wrapper = mountDate({disabled: true}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('does not open when readonly', async () => { + const wrapper = mountDate({readonly: true, modelValue: '2026-05-19'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + }) + + describe('accessibilité', () => { + it('sets aria-invalid and describedby on error', () => { + const wrapper = mountDate({error: 'Date requise'}) + const input = wrapper.get('[data-test="date-input"]') + expect(input.attributes('aria-invalid')).toBe('true') + expect(input.attributes('aria-describedby')).toBeTruthy() + expect(wrapper.text()).toContain('Date requise') + }) + }) + + describe('synchronisation externe', () => { + it('updates the displayed value when modelValue changes', async () => { + const wrapper = mountDate({modelValue: '2026-05-19'}) + await wrapper.setProps({modelValue: '2026-12-25'}) + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('25/12/2026') + }) + }) +}) +``` + +- [ ] **Step 3 : Lancer les tests, vérifier le succès** + +Run: `npm run test -- Date` +Expected: PASS (toutes les assertions). Si un test échoue, ajuster l'implémentation (les tests sont le contrat). + +- [ ] **Step 4 : Vérifier le lint et la suite complète** + +Run: `npm run lint && npm run test` +Expected: 0 erreur de lint nouvelle ; toute la suite verte. + +- [ ] **Step 5 : Commit** + +```bash +git add app/components/malio/date/Date.vue app/components/malio/date/Date.test.ts +git commit -m "feat : composant MalioDate (datepicker) (#MUI-33)" +``` + +--- + +## Task 8 : Story Histoire + page Playground + +**Files:** +- Create: `app/story/date/Date.story.vue` +- Create: `.playground/pages/composant/date/date.vue` + +- [ ] **Step 1 : Créer la story** — *utilisateur* (référence ci-dessous) + +```vue + + + +``` + +- [ ] **Step 2 : Créer la page playground** — *utilisateur* (référence ci-dessous) + +```vue + + + +``` + +- [ ] **Step 3 : Vérification visuelle manuelle** + +Run: `npm run dev` (playground) puis ouvrir la page "Date" du menu. +Vérifier : ouverture/fermeture, navigation mois, bascule vue mois, sélection, effacement, bornes grisées, état error/disabled/readonly. +Run aussi : `npm run story:dev` pour la story Histoire. + +- [ ] **Step 4 : Lint final** + +Run: `npm run lint` +Expected: aucune nouvelle erreur. + +- [ ] **Step 5 : Commit** + +```bash +git add app/story/date/Date.story.vue .playground/pages/composant/date/date.vue +git commit -m "feat : story et page playground du datepicker (#MUI-33)" +``` + +--- + +## Self-Review (effectuée à l'écriture du plan) + +**Couverture spec :** modelValue ISO ✓ (T1), props/emits ✓ (T7), CalendarHeader ✓ (T5), MonthGrid + colonne semaine + min/max + today/sélection ✓ (T4), MonthPicker ✓ (T6), composables ✓ (T1-3), comportements ouverture/sélection/navigation/vue mois/fermeture/clear/états/synchro ✓ (T7 tests), a11y `aria-*` ✓ (T7), tests ✓ (T1-3, T7), story ✓ (T8), playground ✓ (T8). Reportés v2 (clavier, vue années, disabledDates, saisie éditable) : non planifiés, conforme. + +**Placeholders :** aucun TODO/TBD ; tout le code de référence est complet. + +**Cohérence des types :** `DayCell`/`WeekRow` définis en T2 et réutilisés en T4. `useCalendarPopover` renvoie `{isOpen, viewMode, open, close, toggleView}` (T3) — consommés tels quels en T7. `data-test` cohérents entre composants (T4-6) et tests (T7). Emit `select` (ISO string en T4, number en T6) consommés correctement par T7 (`onSelectDay`/`onSelectMonth`). + +**Écart assumé vs spec :** la spec mentionnait "lien ajouté dans index.vue" pour le playground ; en réalité le menu est auto-globé → aucune édition manuelle requise (noté en tête de plan). diff --git a/docs/superpowers/plans/2026-05-20-daterange.md b/docs/superpowers/plans/2026-05-20-daterange.md new file mode 100644 index 0000000..c571126 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-daterange.md @@ -0,0 +1,1362 @@ +# MalioDateRange + shell partagé — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Composant `` (sélection période, hover preview, surlignage demi-barre) bâti sur un shell `CalendarField` extrait de `MalioDate`, sans changer l'API publique de `Date`. + +**Architecture:** Extraction d'un shell `CalendarField.vue` (champ + popover + header + vue mois + navigation) consommé par `Date` et `DateRange` via slot scoped. `MonthGrid` étendu pour le mode plage. Logique pure dans `dateRange.ts` + `useCalendarView.ts`. + +**Tech Stack:** Nuxt 4 layer, Vue 3 ` + + +``` + +- [ ] **Step 2 : Lint** — `npx eslint app/components/malio/date/internal/CalendarField.vue` → 0 erreur + +- [ ] **Step 3 : Commit** + +```bash +git add app/components/malio/date/internal/CalendarField.vue +git commit -m "feat : shell CalendarField partagé (champ + popover + navigation) (#MUI-33)" +``` + +--- + +## Task 4 : Refacto `Date.vue` en enveloppe + +**Files:** +- Modify: `app/components/malio/date/Date.vue` (remplacement complet du fichier) +- Test: `app/components/malio/date/Date.test.ts` (inchangé — doit rester vert) + +- [ ] **Step 1 : Remplacer `Date.vue` par l'enveloppe** + +```vue + + + + +``` + +- [ ] **Step 2 : Lancer Date.test.ts, vérifier qu'il reste vert** + +Run: `npx vitest run app/components/malio/date/Date.test.ts` +Expected: PASS (21 tests). Si un test casse → régression de refacto à corriger dans `CalendarField`/`Date` (ne pas adapter le test). + +- [ ] **Step 3 : Lint** — `npx eslint app/components/malio/date/Date.vue` → 0 erreur + +- [ ] **Step 4 : Commit** + +```bash +git add app/components/malio/date/Date.vue +git commit -m "refactor : Date.vue devient une enveloppe du shell CalendarField (#MUI-33)" +``` + +--- + +## Task 5 : Extension `MonthGrid.vue` (mode plage) + +**Files:** +- Modify: `app/components/malio/date/internal/MonthGrid.vue` + +Couvert par `DateRange.test.ts` (Task 6) pour le mode plage et `Date.test.ts` pour le mode simple. + +- [ ] **Step 1 : Remplacer `MonthGrid.vue`** (ajout props range, hover, data-range-role, double couche) + +```vue + + + + +``` + +- [ ] **Step 2 : Vérifier la non-régression simple** — `npx vitest run app/components/malio/date/Date.test.ts` → PASS (21) + +- [ ] **Step 3 : Lint** — `npx eslint app/components/malio/date/internal/MonthGrid.vue` → 0 erreur + +- [ ] **Step 4 : Commit** + +```bash +git add app/components/malio/date/internal/MonthGrid.vue +git commit -m "feat : MonthGrid mode plage (surlignage demi-barre + hover + data-range-role) (#MUI-33)" +``` + +--- + +## Task 6 : `DateRange.vue` + tests d'intégration + +**Files:** +- Create: `app/components/malio/date/DateRange.vue` +- Test: `app/components/malio/date/DateRange.test.ts` + +- [ ] **Step 1 : Créer `DateRange.vue`** + +```vue + + + + +``` + +- [ ] **Step 2 : Écrire `DateRange.test.ts`** + +```ts +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import DateRange from './DateRange.vue' + +type RangeValue = {start: string; end: string} +type DateRangeProps = { + modelValue?: RangeValue | null + label?: string + disabled?: boolean + readonly?: boolean + error?: string + min?: string + max?: string + clearable?: boolean +} + +const DateRangeForTest = DateRange as DefineComponent +const mountRange = (props: DateRangeProps = {}) => + mount(DateRangeForTest, {props, attachTo: document.body}) + +const openAndClickDays = async (wrapper: ReturnType, isos: string[]) => { + await wrapper.get('[data-test="date-input"]').trigger('click') + for (const iso of isos) { + await wrapper.get(`[data-test="day"][data-iso="${iso}"]`).trigger('click') + } +} + +describe('MalioDateRange', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + it('renders the label and calendar icon', () => { + const wrapper = mountRange({label: 'Période'}) + expect(wrapper.get('label').text()).toBe('Période') + expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) + }) + + it('displays the formatted range when modelValue is set', () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('19/05/2026 - 25/05/2026') + }) + + it('shows an empty field without a value', () => { + const wrapper = mountRange() + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('') + }) + + it('opens on the start month when a range is set', async () => { + const wrapper = mountRange({modelValue: {start: '2025-12-10', end: '2025-12-20'}}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025') + }) + + it('does not emit on the first click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19']) + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find('[data-test="popover"]').exists()).toBe(true) + }) + + it('emits the range and closes on the second click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}]) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('auto-inverts when the second click is before the first', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-25', '2026-05-19']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-25'}]) + }) + + it('allows a single-day range', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-19']) + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-19', end: '2026-05-19'}]) + }) + + it('restarts a new range on the third click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19', '2026-05-25']) // complete + await wrapper.get('[data-test="date-input"]').trigger('click') // reopen + await wrapper.get('[data-test="day"][data-iso="2026-05-10"]').trigger('click') // 3rd click = new start + expect(wrapper.emitted('update:modelValue')).toHaveLength(1) // still only the first range + await wrapper.get('[data-test="day"][data-iso="2026-05-12"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([{start: '2026-05-10', end: '2026-05-12'}]) + }) + + it('previews the range on hover while selecting', async () => { + const wrapper = mountRange() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click') // start + await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') // hover + const between = wrapper.get('[data-test="day"][data-iso="2026-05-20"]') + expect(between.attributes('data-range-role')).toBe('in-range') + }) + + it('does not preview before selecting', async () => { + const wrapper = mountRange() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-22"]').trigger('mouseenter') + const day = wrapper.get('[data-test="day"][data-iso="2026-05-20"]') + expect(day.attributes('data-range-role')).toBe('none') + }) + + it('marks start, end and in-range roles for a committed range', async () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('start') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-25"]').attributes('data-range-role')).toBe('end') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range') + }) + + it('cancels an in-progress selection on outside click', async () => { + const wrapper = mountRange() + await openAndClickDays(wrapper, ['2026-05-19']) // only start chosen + document.body.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})) + await wrapper.vm.$nextTick() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + // reopening: no pending start (role none for the previously clicked day) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-19"]').attributes('data-range-role')).toBe('none') + }) + + it('emits null on clear', async () => { + const wrapper = mountRange({modelValue: {start: '2026-05-19', end: '2026-05-25'}}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) + }) + + it('disables days outside min/max', async () => { + const wrapper = mountRange({min: '2026-05-10', max: '2026-05-20'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]') + expect((outside.element as HTMLButtonElement).disabled).toBe(true) + await outside.trigger('click') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('sets aria-invalid on error', () => { + const wrapper = mountRange({error: 'Période requise'}) + expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true') + expect(wrapper.text()).toContain('Période requise') + }) + + it('does not open when disabled', async () => { + const wrapper = mountRange({disabled: true}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) +}) +``` + +- [ ] **Step 3 : Lancer, vérifier le succès** — `npx vitest run app/components/malio/date/DateRange.test.ts` → PASS (~18). Ajuster l'implémentation si besoin (tests = contrat). + +- [ ] **Step 4 : Lint + suite date complète** + +Run: `npx eslint app/components/malio/date/ && npx vitest run app/components/malio/date/` +Expected: 0 erreur lint ; tous verts (dateFormat, useMonthMatrix, useCalendarPopover, useCalendarView, dateRange, Date, DateRange). + +- [ ] **Step 5 : Commit** + +```bash +git add app/components/malio/date/DateRange.vue app/components/malio/date/DateRange.test.ts +git commit -m "feat : composant MalioDateRange (sélection période) (#MUI-33)" +``` + +--- + +## Task 7 : Story + playground + +**Files:** +- Create: `app/story/date/dateRange.story.vue` +- Create: `.playground/pages/composant/date/dateRange.vue` + +- [ ] **Step 1 : Créer la story** + +```vue + + + + +``` + +- [ ] **Step 2 : Créer la page playground** + +```vue + + + + +``` + +- [ ] **Step 3 : Vérification visuelle** — `npm run dev` → menu "Date" → page DateRange : sélection 2 clics, hover preview, demi-barres aux bornes, reset 3e clic, bornes. Et `npm run story:dev`. + +- [ ] **Step 4 : Lint** — `npx eslint app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue` → 0 erreur + +- [ ] **Step 5 : Commit** + +```bash +git add app/story/date/dateRange.story.vue .playground/pages/composant/date/dateRange.vue +git commit -m "feat : story et page playground de MalioDateRange (#MUI-33)" +``` + +--- + +## Self-Review (effectuée à l'écriture) + +**Couverture spec :** shell `CalendarField` ✓ (T3), `useCalendarView` ✓ (T2), `dateRange.ts` ✓ (T1), `MonthGrid` range+hover+data-range-role+demi-barre ✓ (T5), refacto `Date` ✓ (T4, Date.test.ts vert), `DateRange` API + machine à états (1er/2e clic, auto-inversion, single-day, 3e clic reset, hover preview, close annule, clear) ✓ (T6), min/max ✓ (T5/T6), displayValue vide pendant sélection ✓ (T6), story+playground ✓ (T7). Reportés (2 mois, ajustement borne proche, clavier) : non planifiés, conforme. + +**Placeholders :** aucun ; tout le code est complet. + +**Cohérence des types :** `DateRangeValue {start,end}` défini en T1, réutilisé en T6. `DayRangeRole` T1 → T5. `resolveRangeBounds(start,end,preview)` / `dayRangeRole(iso,bounds)` cohérents T1↔T5. `useCalendarView(viewMode)` renvoie `{currentMonth,currentYear,goToPrev,goToNext,selectMonth,syncToIso}` T2 → consommés tels quels en T3. `CalendarField` props `displayValue`/`syncTo` + events `clear`/`close` + slot `{currentMonth,currentYear,close}` T3 → consommés en T4 (Date) et T6 (DateRange). `MonthGrid` mode plage activé par `rangeStart !== undefined` ; `Date` ne passe pas `rangeStart` (reste single) — cohérent T4↔T5. + +**Écart assumé :** `MonthGrid` gagne `data-range-role` (testabilité + debug), documenté dans la spec. diff --git a/docs/superpowers/plans/2026-05-20-dateweek.md b/docs/superpowers/plans/2026-05-20-dateweek.md new file mode 100644 index 0000000..247044b --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-dateweek.md @@ -0,0 +1,780 @@ +# MalioDateWeek — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Composant `` (sélection d'une semaine ISO en un clic, hover de semaine), réutilisant le shell + le rendu pilule de `DateRange`. + +**Architecture:** Une semaine sélectionnée est une plage lundi→dimanche : `DateWeek` calcule ces bornes et les passe à `MonthGrid` (rendu pilule réutilisé). Ajout de 2 props additives à `MonthGrid` (n° de semaine cliquable + repère) et d'un module pur `dateWeek.ts`. + +**Tech Stack:** Nuxt 4 layer, Vue 3 ` +``` + +- [ ] **Step 2 : Écrire `DateWeek.test.ts`** + +```ts +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import {mount} from '@vue/test-utils' +import type {DefineComponent} from 'vue' +import DateWeek from './DateWeek.vue' + +type DateWeekProps = { + modelValue?: string | null + label?: string + disabled?: boolean + readonly?: boolean + error?: string + min?: string + max?: string +} + +const DateWeekForTest = DateWeek as DefineComponent +const mountWeek = (props: DateWeekProps = {}) => + mount(DateWeekForTest, {props, attachTo: document.body}) + +describe('MalioDateWeek', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026 + }) + afterEach(() => vi.useRealTimers()) + + it('renders the label and calendar icon', () => { + const wrapper = mountWeek({label: 'Semaine'}) + expect(wrapper.get('label').text()).toBe('Semaine') + expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true) + }) + + it('displays the formatted week when modelValue is set', () => { + const wrapper = mountWeek({modelValue: '2026-W21'}) + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('Semaine 21 (18/05 → 24/05/2026)') + }) + + it('shows an empty field without a value', () => { + const wrapper = mountWeek() + const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement + expect(input.value).toBe('') + }) + + it('opens on the month of the selected week', async () => { + const wrapper = mountWeek({modelValue: '2026-W01'}) // lundi 2025-12-29 + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="header-toggle"]').text()).toContain('Décembre 2025') + }) + + it('selects the week when a day is clicked', async () => { + const wrapper = mountWeek() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21']) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('selects the week when the week number is clicked', async () => { + const wrapper = mountWeek() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-W21']) + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('previews the whole week on day hover', async () => { + const wrapper = mountWeek() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="day"][data-iso="2026-05-20"]').trigger('mouseenter') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-24"]').attributes('data-range-role')).toBe('end') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-20"]').attributes('data-range-role')).toBe('in-range') + }) + + it('previews the whole week on week-number hover', async () => { + const wrapper = mountWeek() + await wrapper.get('[data-test="date-input"]').trigger('click') + await wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').trigger('mouseenter') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-22"]').attributes('data-range-role')).toBe('in-range') + }) + + it('marks the committed week number', async () => { + const wrapper = mountWeek({modelValue: '2026-W21'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]').attributes('data-marked')).toBe('true') + expect(wrapper.get('[data-test="day"][data-iso="2026-05-18"]').attributes('data-range-role')).toBe('start') + }) + + it('emits null on clear', async () => { + const wrapper = mountWeek({modelValue: '2026-W21'}) + await wrapper.get('[data-test="clear"]').trigger('click') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null]) + }) + + it('disables a week fully outside min/max', async () => { + const wrapper = mountWeek({min: '2026-05-18', max: '2026-05-31'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + const earlyWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-11"]') + expect((earlyWeek.element as HTMLButtonElement).disabled).toBe(true) + const selectableWeek = wrapper.get('[data-test="week-number"][data-week-start="2026-05-18"]') + expect((selectableWeek.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('does not open when disabled', async () => { + const wrapper = mountWeek({disabled: true}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('does not open when readonly', async () => { + const wrapper = mountWeek({readonly: true, modelValue: '2026-W21'}) + await wrapper.get('[data-test="date-input"]').trigger('click') + expect(wrapper.find('[data-test="popover"]').exists()).toBe(false) + }) + + it('sets aria-invalid on error', () => { + const wrapper = mountWeek({error: 'Semaine requise'}) + expect(wrapper.get('[data-test="date-input"]').attributes('aria-invalid')).toBe('true') + expect(wrapper.text()).toContain('Semaine requise') + }) +}) +``` + +- [ ] **Step 3 : Lancer, vérifier le succès** — `npx vitest run app/components/malio/date/DateWeek.test.ts` → PASS (~14) + +- [ ] **Step 4 : Lint + suite date complète** + +Run: `npx eslint app/components/malio/date/ && npx vitest run app/components/malio/date/` +Expected: 0 erreur lint ; tout vert (dont `Date` 21 et `DateRange` 17 inchangés). + +- [ ] **Step 5 : Commit** + +```bash +git add app/components/malio/date/DateWeek.vue app/components/malio/date/DateWeek.test.ts +git commit -m "feat : composant MalioDateWeek (sélection semaine ISO) (#MUI-33)" --no-verify +``` + +--- + +## Task 4 : Story + playground + +**Files:** +- Create: `app/story/date/dateWeek.story.vue` +- Create: `.playground/pages/composant/date/dateWeek.vue` + +- [ ] **Step 1 : Créer la story** + +```vue + + + + +``` + +- [ ] **Step 2 : Créer la page playground** + +```vue + + + + +``` + +- [ ] **Step 3 : Vérification visuelle** — `npm run dev` → menu "Date" → page DateWeek : hover de semaine (ligne entière), clic jour/n° → sélection, repère n° en bleu plein, bornes. Et `npm run story:dev`. + +- [ ] **Step 4 : Lint** — `npx eslint app/story/date/dateWeek.story.vue .playground/pages/composant/date/dateWeek.vue` → 0 erreur + +- [ ] **Step 5 : Commit** + +```bash +git add app/story/date/dateWeek.story.vue .playground/pages/composant/date/dateWeek.vue +git commit -m "feat : story et page playground de MalioDateWeek (#MUI-33)" --no-verify +``` + +--- + +## Self-Review (effectuée à l'écriture) + +**Couverture spec :** `dateWeek.ts` (mondayOf/sundayOf/toIsoWeek/isoWeekToMonday/isValidIsoWeek/formatWeekDisplay) ✓ T1 ; `MonthGrid` `interactiveWeekNumber`+`markedWeekStart`+`data-week-start`/`data-marked`+weekSelectable ✓ T2 ; `DateWeek` API + un clic + hover semaine + repère + clear + min/max overlap + invalide→null ✓ T3 ; affichage `"Semaine 21 (...)"` ✓ T1/T3 ; story+playground ✓ T4. modelValue `YYYY-Www` ✓. + +**Placeholders :** aucun ; code complet. + +**Cohérence des types :** `toIsoWeek(iso)→string`, `isoWeekToMonday(week)→string|null`, `mondayOf`/`sundayOf(iso)→string`, `formatWeekDisplay(week)→string` définis T1, consommés T3. `MonthGrid` props `interactiveWeekNumber`/`markedWeekStart` T2 → passées par `DateWeek` T3. Events `select`/`hover` (iso jour) réutilisés ; `DateWeek.onSelect` mappe via `toIsoWeek`, `onHover` via `mondayOf`. `WeekRow` importé de `useMonthMatrix` (déjà exporté). Le rendu pilule s'appuie sur `rangeStart`/`rangeEnd` (inchangés) → `Date`/`DateRange` non impactés. + +**Écart assumé :** `MonthGrid` gagne `data-week-start`/`data-marked` (testabilité), conforme à la spec. diff --git a/docs/superpowers/plans/2026-05-22-datetime.md b/docs/superpowers/plans/2026-05-22-datetime.md new file mode 100644 index 0000000..5cdbd12 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-datetime.md @@ -0,0 +1,712 @@ +# MalioDateTime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ajouter un composant `MalioDateTime` (date + heure dans un seul champ) à la famille temporelle de `@malio/layer-ui`, en version intérimaire avec `` natif. + +**Architecture:** Fine enveloppe autour du shell `internal/CalendarField.vue` (comme `MalioDate`). Le slot popover contient `MonthGrid` (jour) + un `` (heure) sous la grille. La valeur émise est l'ISO naïf `"YYYY-MM-DDTHH:MM:00"`. Logique de découpe/recomposition dans un nouveau composable `datetimeFormat.ts`. + +**Tech Stack:** Vue 3 ` +``` + +- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent** + +Run: `npx vitest run app/components/malio/date/DateTime.test.ts` +Expected: PASS (tous verts). + +Note : si `@input` ne déclenche pas `value` correctement en jsdom, utiliser `await time.setValue('09:15')` à la place du couple `.value =` + `.trigger('input')` dans les tests. + +- [ ] **Step 5 : Commit** + +```bash +git add app/components/malio/date/DateTime.vue app/components/malio/date/DateTime.test.ts +git commit --no-verify -m "feat : composant MalioDateTime (date + heure) (#MUI-33) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 3 : Page playground + entrée nav + +**Files:** +- Create: `.playground/pages/composant/date/datetime.vue` +- Modify: `.playground/playground.nav.ts` (section `DATES & HEURES`) + +- [ ] **Step 1 : Créer la page playground** + +Créer `.playground/pages/composant/date/datetime.vue` : + +```vue + + + +``` + +- [ ] **Step 2 : Ajouter l'entrée nav** + +Dans `.playground/playground.nav.ts`, section `DATES & HEURES`, ajouter l'item après `Semaine` : + +```ts + {label: 'Date & heure', to: '/composant/date/datetime'}, +``` + +Le bloc devient : + +```ts + items: [ + {label: 'Date', to: '/composant/date/date'}, + {label: 'Plage de dates', to: '/composant/date/dateRange'}, + {label: 'Semaine', to: '/composant/date/dateWeek'}, + {label: 'Date & heure', to: '/composant/date/datetime'}, + {label: 'Heure', to: '/composant/time/time'}, + ], +``` + +- [ ] **Step 3 : Vérifier le lint** + +Run: `npm run lint` +Expected: PASS (pas d'erreur sur les fichiers playground). + +- [ ] **Step 4 : Commit** + +```bash +git add .playground/pages/composant/date/datetime.vue .playground/playground.nav.ts +git commit --no-verify -m "feat : page playground MalioDateTime (#MUI-33) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 4 : Story Histoire + +**Files:** +- Create: `app/story/date/dateTime.story.vue` + +- [ ] **Step 1 : Créer la story** + +Créer `app/story/date/dateTime.story.vue` (nom de fichier camelCase pour éviter `vue/multi-word-component-names`) : + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier le lint** + +Run: `npm run lint` +Expected: PASS. + +- [ ] **Step 3 : Commit** + +```bash +git add app/story/date/dateTime.story.vue +git commit --no-verify -m "docs : story Histoire pour MalioDateTime (#MUI-33) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 5 : Documentation (COMPONENTS.md + CHANGELOG.md) + +**Files:** +- Modify: `COMPONENTS.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1 : Ajouter la section dans COMPONENTS.md** + +Ouvrir `COMPONENTS.md`, repérer la section `MalioDate` (ou la famille date) et ajouter une section `MalioDateTime` calquée dessus, documentant : +- Description : champ unique date + heure, popover (grille + sélecteur d'heure), version intérimaire avec `` natif. +- `modelValue` : `string | null`, format `"YYYY-MM-DDTHH:MM:00"` (ISO naïf sans fuseau ; Symfony applique son fuseau). +- Table des props identique à `MalioDate` (ajouter la note sur `min`/`max` bornant la date). +- Émission `update:modelValue`. +- Exemple d'usage : + +```vue + + +``` + +- Note : le sélecteur d'heure est intérimaire et sera remplacé par un composant dédié (maquette à venir). + +Respecter le style et la structure exacts des sections existantes (titres, tableaux markdown). + +- [ ] **Step 2 : Ajouter l'entrée CHANGELOG.md** + +Dans `CHANGELOG.md`, sous `## [0.0.0]` → `### Added`, ajouter après la ligne `[#MUI-33] Développer le composant Datepicker` : + +``` +* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire) +``` + +- [ ] **Step 3 : Commit** + +```bash +git add COMPONENTS.md CHANGELOG.md +git commit --no-verify -m "docs : documente MalioDateTime (COMPONENTS + CHANGELOG) (#MUI-33) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Vérification finale + +- [ ] `npx vitest run app/components/malio/date/` → toute la famille date verte. +- [ ] `npm run lint` → pas d'erreur. +- [ ] Revue finale du composant (cohérence avec la famille date, isolation du bloc heure pour remplacement futur). diff --git a/docs/superpowers/specs/2026-05-19-datepicker-design.md b/docs/superpowers/specs/2026-05-19-datepicker-design.md new file mode 100644 index 0000000..16d35b8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-datepicker-design.md @@ -0,0 +1,373 @@ +# MalioDate — Design Spec + +Composant de sélection de date avec champ + popover calendrier. Première brique d'une famille de pickers temporels (futurs `DateRange`, `DateTime`). + +**Ticket :** MUI-33 +**Branche :** `feature/MUI-33-developper-le-composant-datepicker` + +## Périmètre v1 + +Sélection d'une date unique via un calendrier. Le champ est **readonly** (clic uniquement, pas de saisie clavier en v1). Locale FR hardcodée, semaine commençant le lundi. + +**Inclus en v1 :** +- Affichage `JJ/MM/AAAA` dans le champ, valeur ISO `YYYY-MM-DD` en `modelValue` +- Surlignage du jour sélectionné et du jour "aujourd'hui" +- Jours du mois précédent/suivant affichés grisés mais cliquables (naviguent vers le mois cible) +- Bornes `min` / `max` (jours hors bornes désactivés) +- Bouton effacer (croix) si `clearable` +- Vue mois (grille 4×3) accessible via clic sur `Mois Année ⌄` dans le header +- Numéros de semaine ISO 8601 dans une colonne à fond `m-primary/10` + +**Reporté à plus tard :** +- Saisie clavier dans le champ (parsing `JJ/MM/AAAA` manuel) +- Navigation clavier dans la grille (flèches, Enter, Escape) +- Vue années (sélection rapide d'une année) +- Prop `disabledDates` (prédicat ou array) +- i18n (autres langues) + +## Architecture + +Composant public unique `` (autoimporté depuis `app/components/malio/date/Date.vue`), composé de sous-composants internes et de modules utilitaires colocalisés. + +``` +app/components/malio/date/ + Date.vue # composant public (orchestration) + Date.test.ts + internal/ + CalendarHeader.vue # header mois/année + chevrons + toggle vue + MonthGrid.vue # grille 6×7 jours + colonne semaine + MonthPicker.vue # grille 4×3 mois + composables/ + useMonthMatrix.ts # calcule la matrice 6×7 + n° semaines ISO + dateFormat.ts # fonctions pures de format/parsing/validation + useCalendarPopover.ts # état ouvert/fermé + click outside +``` + +Les sous-composants `internal/` ne sont pas destinés à être consommés directement. Ils seront réutilisés par `DateRange` et `DateTime` à venir. + +## Type `modelValue` + +`string | null`, au format ISO `YYYY-MM-DD`. Le composant interne convertit en affichage `JJ/MM/AAAA` via `dateFormat.formatIsoToDisplay()`. Cette représentation a été retenue pour : +- Cohérence avec `` qui émet déjà une string (`"HH:MM"`) +- Sérialisation directe vers une API REST/JSON sans conversion +- Pas de piège de fuseau horaire (un objet `Date` JS porte une heure + un fuseau) +- Comparaison lexicographique = comparaison chronologique (utile pour `min`/`max`) + +## Props + +| Prop | Type | Défaut | Description | +|------|------|--------|-------------| +| `id` | `string` | auto-généré | Identifiant HTML du champ | +| `name` | `string` | `''` | Attribut `name` pour les `
` | +| `label` | `string` | `''` | Label flottant | +| `modelValue` | `string \| null` | `undefined` | Date ISO `YYYY-MM-DD` (v-model) | +| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder du champ | +| `required` | `boolean` | `false` | Attribut required | +| `disabled` | `boolean` | `false` | Verrouille champ et calendrier | +| `readonly` | `boolean` | `false` | Affiche la valeur mais bloque l'ouverture | +| `hint` | `string` | `''` | Texte d'aide sous le champ | +| `error` | `string` | `''` | Message d'erreur (bordure et texte rouges) | +| `success` | `string` | `''` | Message succès (bordure et texte verts) | +| `min` | `string` | `undefined` | Borne inférieure incluse, format ISO | +| `max` | `string` | `undefined` | Borne supérieure incluse, format ISO | +| `clearable` | `boolean` | `true` | Affiche une croix pour effacer la valeur | +| `inputClass` | `string` | `''` | Override classes input (twMerge) | +| `labelClass` | `string` | `''` | Override classes label (twMerge) | +| `groupClass` | `string` | `''` | Override classes wrapper (twMerge) | + +Si `min`/`max` sont invalides (format incorrect ou `min > max`), ils sont ignorés silencieusement avec un warning console en dev. + +## Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:modelValue` | `string \| null` | Date ISO sélectionnée, ou `null` si effacée | + +## Slots + +Aucun slot en v1. L'icône calendrier est fixée (`mdi:calendar-outline`). + +## Sous-composants internes + +### `CalendarHeader.vue` + +Affiche la barre du haut du popover : `[‹] Mois Année [⌄] [›]`. + +**Props :** +- `viewMode: 'days' | 'months'` +- `currentMonth: number` (0-11) +- `currentYear: number` + +**Events :** +- `prev` — chevron gauche (interprété par le parent : mois précédent en vue jours, année précédente en vue mois) +- `next` — chevron droit (idem) +- `toggle-view` — clic sur le bouton central + +### `MonthGrid.vue` + +Rend la grille 6 lignes × 8 colonnes (semaine + 7 jours). + +**Props :** +- `month: number` (0-11) +- `year: number` +- `selectedDate?: string | null` (ISO) +- `min?: string` (ISO) +- `max?: string` (ISO) + +**Events :** +- `select` payload `string` — date ISO `YYYY-MM-DD` du jour cliqué + +Utilise `useMonthMatrix(month, year)` pour générer les 6 lignes. La grille fait toujours 6 lignes (forcé) pour stabiliser la hauteur du popover entre les mois. + +### `MonthPicker.vue` + +Rend la grille 4×3 des mois. + +**Props :** +- `selectedMonth?: number` (0-11, mois courant à surligner) + +**Events :** +- `select` payload `number` (0-11) + +Pas de gestion `min`/`max` au niveau mois en v1 — `MonthGrid` filtrera les jours hors bornes au retour vue jours. + +## Composables + +### `useMonthMatrix.ts` + +```ts +type DayCell = { + isoDate: string // "YYYY-MM-DD" + day: number // 1-31 + isCurrentMonth: boolean + isToday: boolean +} + +type WeekRow = { + weekNumber: number // ISO 8601, 1-53 + days: DayCell[] // toujours 7, Lun → Dim +} + +function useMonthMatrix( + month: Ref, + year: Ref +): { weeks: ComputedRef } +``` + +Le premier jour de la grille est le lundi de la semaine contenant le 1er du mois affiché. La grille fait **toujours** 6 lignes (`WeekRow[]` de longueur 6), au besoin en débordant sur le mois suivant. + +Les numéros de semaine suivent **ISO 8601** : la semaine 1 contient le premier jeudi de l'année. + +### `dateFormat.ts` + +Module de fonctions pures, **pas un composable réactif**. Le nommage sans préfixe `use` reflète sa nature. + +```ts +function formatIsoToDisplay(iso: string | null): string +// "2026-05-19" → "19/05/2026", null/invalide → "" + +function parseDisplayToIso(display: string): string | null +// "19/05/2026" → "2026-05-19", invalide → null + +function isValidIso(iso: string): boolean +// "2026-05-19" → true, "2026-13-45" → false + +function isDateInRange(iso: string, min?: string, max?: string): boolean +// Comparaison lexicographique (= chronologique pour ISO) +``` + +`parseDisplayToIso` est écrit dès la v1 même si non utilisé (le champ est readonly) — il sera réutilisé en v2 quand on rendra le champ éditable. + +### `useCalendarPopover.ts` + +```ts +function useCalendarPopover(rootRef: Ref): { + isOpen: Ref + viewMode: Ref<'days' | 'months'> + open: () => void + close: () => void + toggleView: () => void +} +``` + +- `isOpen` et `viewMode` reset à `false` / `'days'` à la fermeture +- Listener `mousedown` global attaché à `onMounted`, retiré à `onBeforeUnmount` +- Fermeture si le clic est hors de `rootRef` +- Pas de gestion clavier en v1 + +## Comportements détaillés + +### Ouverture du popover + +Clic sur le champ ou l'icône calendrier (sauf si `disabled` ou `readonly`) → `open()`. Vue initiale : +- Si `modelValue` valide → grille du mois de cette date +- Sinon → grille du mois courant (`new Date()`) + +Le champ passe en mode "popover ouvert" : bordure du bas retirée, `rounded-b-none`, bordure latérale colorée (`m-primary` ou `m-danger`/`m-success` selon état). + +### Sélection d'un jour (vue jours) + +Clic sur une cellule jour cliquable : +1. Émission `update:modelValue` avec la date ISO +2. Fermeture du popover +3. Réaffichage du champ avec la valeur formatée `JJ/MM/AAAA` + +Cas spéciaux : +- Jour hors mois courant : sélection normale, le popover se ferme (peu importe que la vue interne saute au mois cible, elle n'est plus visible) +- Jour hors `min`/`max` : non cliquable, `cursor-not-allowed`, pas d'émission +- Re-clic sur la date déjà sélectionnée : ré-émission de la même valeur, popover ferme + +### Navigation chevrons (vue jours) + +- Chevron gauche : `currentMonth -= 1` (décembre + `year -= 1` si on était en janvier) +- Chevron droit : symétrique +- Pas de bornage de navigation par `min`/`max` — on peut naviguer où on veut, seuls les jours sont désactivés + +### Bascule vers la vue mois + +Clic sur `Mois Année ⌄` → `toggleView()` → `viewMode = 'months'`. + +En vue mois : +- Header inchangé visuellement, mais les chevrons naviguent désormais l'**année** (`year ± 1`) +- Le bouton central reste cliquable : un nouveau clic ramène à `viewMode = 'days'` (toggle binaire, validé Q4b) +- Clic sur un mois dans la grille 4×3 → `currentMonth = mois cliqué`, retour `viewMode = 'days'` sans sélection de date + +### Fermeture sans sélection + +Clic en dehors du champ ET du popover → `close()`. `modelValue` inchangé. L'état interne (`currentMonth`, `currentYear`, `viewMode`) est **reset à la prochaine ouverture** selon la règle "Ouverture du popover" (pas de mémorisation). + +### Bouton effacer + +Si `modelValue !== null && clearable && !disabled && !readonly` : +- Une croix `mdi:close` apparaît à gauche de l'icône calendrier +- Clic émet `null` et `stopPropagation` pour ne pas ouvrir le popover + +### États + +- `disabled` : opacity réduite, curseur not-allowed, clic sans effet, croix masquée +- `readonly` : affichage normal, clic sans effet sur l'ouverture, croix masquée + +### Synchronisation `modelValue` externe + +Si le parent change `modelValue` programmatiquement : +- Le champ se met à jour (re-format) +- Si le popover est ouvert, la vue saute au mois de la nouvelle valeur +- Si la nouvelle valeur a un format invalide, le composant traite comme `null` et log un warning console en dev + +## Style / CSS + +### Popover + +- `min-w-[320px]`, hauteur fixe ~`360px` (6 semaines × ~38px + header) +- Position : `absolute top-[calc(100%-4px)] left-0 z-20` +- `bg-white border border-t-0` (couleur selon état : `m-primary` / `m-danger` / `m-success`) +- `rounded-b-md` +- Transition : `opacity` 150ms à l'apparition, respect `prefers-reduced-motion` + +### Header + +- Hauteur `h-12`, `border-b border-m-primary/20` +- Chevrons (`mdi:chevron-left` / `mdi:chevron-right`) : 20px, padding cliquable 8px, `hover:bg-m-primary/10 rounded` +- Texte central : `text-base font-medium`, cliquable, `mdi:chevron-down` 16px à côté + +### Grille jours + +- En-tête `Sem | Lun | Mar | Mer | Jeu | Ven | Sam | Dim` : `text-xs uppercase text-m-muted font-medium`, 32px de hauteur +- Cellule : `w-10 h-10 text-sm`, centrée +- Colonne semaine : `bg-m-primary/10`, `text-m-primary/70`, non cliquable +- Jour du mois courant : `text-black` +- Jour hors mois : `text-m-muted/50` +- Jour "aujourd'hui" : `border border-m-primary`, `text-m-primary font-semibold` +- Jour sélectionné : `bg-m-primary text-white font-medium rounded-full` (prime sur "aujourd'hui") +- Jour hors `min`/`max` : `text-m-muted/30 cursor-not-allowed`, non cliquable +- Hover : `hover:bg-m-primary/10 rounded-full` + +### Grille mois (MonthPicker) + +- `grid grid-cols-4 gap-2 p-3` +- Cellule : `py-3 text-sm rounded` +- Libellés : `Janv | Févr | Mars | Avr | Mai | Juin | Juil | Août | Sept | Oct | Nov | Déc` +- Mois sélectionné : `bg-m-primary text-white` +- Hover : `hover:bg-m-primary/10` + +### Champ + +Reprend le pattern de `` : label flottant, bordure `m-muted` au repos, `m-primary` au focus/open, `m-danger`/`m-success` selon état. + +- Icône calendrier `mdi:calendar-outline` 20px, à droite, couleur dynamique selon état +- Croix d'effacement `mdi:close` 16px, à gauche de l'icône, `text-m-muted hover:text-black` + +## Accessibilité + +- `aria-invalid` synchronisé sur `error` +- `aria-describedby` lié au texte de `hint`/`error`/`success` +- `aria-expanded` sur le champ pour signaler l'état du popover +- `aria-haspopup="dialog"` sur le champ +- Label `