From b764f2718692e07b6b9981673200e4918e12ead4 Mon Sep 17 00:00:00 2001 From: tristan Date: Wed, 20 May 2026 11:56:05 +0200 Subject: [PATCH] =?UTF-8?q?feat=20:=20composant=20MalioDateRange=20(s?= =?UTF-8?q?=C3=A9lection=20p=C3=A9riode)=20(#MUI-33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- app/components/malio/date/DateRange.test.ts | 155 ++++++++++++++++++++ app/components/malio/date/DateRange.vue | 140 ++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 app/components/malio/date/DateRange.test.ts create mode 100644 app/components/malio/date/DateRange.vue 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 @@ + + +