diff --git a/.playground/pages/composant/date/date.vue b/.playground/pages/composant/date/date.vue new file mode 100644 index 0000000..382d982 --- /dev/null +++ b/.playground/pages/composant/date/date.vue @@ -0,0 +1,53 @@ + + + diff --git a/app/components/malio/date/Date.test.ts b/app/components/malio/date/Date.test.ts new file mode 100644 index 0000000..9616b32 --- /dev/null +++ b/app/components/malio/date/Date.test.ts @@ -0,0 +1,198 @@ +import {afterEach, beforeEach, describe, expect, it, 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') + }) + }) +}) diff --git a/app/components/malio/date/Date.vue b/app/components/malio/date/Date.vue new file mode 100644 index 0000000..ae80a9f --- /dev/null +++ b/app/components/malio/date/Date.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/app/components/malio/date/internal/CalendarHeader.vue b/app/components/malio/date/internal/CalendarHeader.vue new file mode 100644 index 0000000..03d7920 --- /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..6bdab10 --- /dev/null +++ b/app/components/malio/date/internal/MonthGrid.vue @@ -0,0 +1,93 @@ + + + diff --git a/app/components/malio/date/internal/MonthPicker.vue b/app/components/malio/date/internal/MonthPicker.vue new file mode 100644 index 0000000..ec67dd6 --- /dev/null +++ b/app/components/malio/date/internal/MonthPicker.vue @@ -0,0 +1,32 @@ + + + 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 @@ + + +