1 Commits

Author SHA1 Message Date
f3a18ace1d feat: composant saisie assistée, composant téléphone et composant mail (#47)
All checks were successful
Release / release (push) Successful in 1m12s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: matthieu <matthieu@yuno.malio.fr>
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #47
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-13 07:01:30 +00:00
38 changed files with 2 additions and 6842 deletions

View File

@@ -12,9 +12,7 @@
"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(npx eslint *)",
"Bash(echo \"LINT EXIT: $?\")"
"Bash(mv inputCheckbox.story.vue checkbox/)"
]
}
}

View File

@@ -1,68 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDate</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDate
v-model="value"
label="Date de naissance"
hint="Clique pour ouvrir le calendrier"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-12-25'"
>
Forcer le 25/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDate
v-model="erpValue"
label="Date du rendez-vous"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur (ISO) : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDate
v-model="bounded"
label="Date bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>

View File

@@ -1,72 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateRange</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateRange
v-model="value"
label="Période"
hint="Clique deux fois pour définir une plage"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ value?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ value?.end ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = {start: '2026-12-20', end: '2026-12-31'}"
>
Forcer 2031/12/2026
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateRange
v-model="erpValue"
label="Période"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Début : <code>{{ erpValue?.start ?? 'null' }}</code></p>
<p>Fin : <code>{{ erpValue?.end ?? 'null' }}</code></p>
</div>
<MalioDateRange
v-model="bounded"
label="Plage bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
type RangeValue = {start: string; end: string}
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const value = ref<RangeValue | null>(null)
const erpValue = ref<RangeValue | null>(null)
const bounded = ref<RangeValue | null>(null)
</script>

View File

@@ -1,68 +0,0 @@
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateWeek</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateWeek
v-model="value"
label="Semaine"
hint="Clique un jour ou un n° de semaine"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-W52'"
>
Forcer 2026-W52
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateWeek
v-model="erpValue"
label="Semaine"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateWeek
v-model="bounded"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>

View File

@@ -78,10 +78,7 @@
<div class="grid grid-cols-3 gap-x-[80px] gap-y-8 mt-12 shadow-[0_4px_4px_0_rgba(0,0,0,0.25)] py-4 pl-[28px] pr-[60px]">
<MalioInputTextArea label="Descritpion" resize="none" groupClass="row-span-2" textInput="h-full"/>
<MalioInputText v-model="concurrent" label="Concurrent"/>
<MalioDate
v-model="dateCreation"
label="Date création"
/>
<MalioInputText label="Date création"/>
<MalioInputText label="Nombre de salariés" />
<MalioInputAmount label="CA"/>
<MalioInputText label="Dirigeant" />
@@ -161,7 +158,6 @@
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import MalioDate from "../../../../app/components/malio/date/Date.vue";
type Commune = {
nom: string
@@ -283,7 +279,6 @@ const onSearchAdresse = async (query: string) => {
const tabsValue = ref('information')
const concurrent = ref('')
const dateCreation = ref<string | null>(null)
const informationValid = computed(() => concurrent.value.trim().length > 0)
const adressesValid = computed(() => /^\d{5}$/.test(codePostal.value))

View File

@@ -6,7 +6,6 @@
:root {
/* ── Globales ── */
--m-primary: 34 39 131; /* #222783 - Bleu Malio */
--m-primary-light: 239 239 253; /* #EFEFFD - Teinte claire du primary (fonds doux) */
--m-bg: 243 244 248; /* #F3F4F8 - Fond de page */
--m-surface: 243 244 248; /* #F3F4F8 - Fond hover/cartes */
--m-text: 15 23 42; /* #0F172A */

View File

@@ -1,198 +0,0 @@
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<DateProps>
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')
})
})
})

View File

@@ -1,93 +0,0 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="modelValue ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="emit('update:modelValue', null)"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:selected-date="modelValue ?? null"
:min="min"
:max="max"
@select="(iso) => { emit('update:modelValue', iso); close() }"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, watch} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
defineOptions({name: 'MalioDate', inheritAttrs: false})
const props = withDefaults(
defineProps<{
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
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
watch(() => props.modelValue, (val) => {
if (val && !isValidIso(val) && import.meta.dev) {
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
}
})
</script>

View File

@@ -1,155 +0,0 @@
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<DateRangeProps>
const mountRange = (props: DateRangeProps = {}) =>
mount(DateRangeForTest, {props, attachTo: document.body})
const openAndClickDays = async (wrapper: ReturnType<typeof mountRange>, 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)
})
})

View File

@@ -1,140 +0,0 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validRange?.start ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="rangeStart"
:range-end="rangeEnd"
:preview-date="previewDate"
:min="min"
:max="max"
@select="(iso) => onSelectDay(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
import {normalizeRange, type DateRangeValue} from './composables/dateRange'
defineOptions({name: 'MalioDateRange', inheritAttrs: false})
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue?: DateRangeValue | 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
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: DateRangeValue | null): void}>()
const pendingStart = ref<string | null>(null)
const hoverDate = ref<string | null>(null)
const isSelecting = computed(() => pendingStart.value !== null)
const validRange = computed<DateRangeValue | null>(() => {
const v = props.modelValue
if (v && isValidIso(v.start) && isValidIso(v.end)) return v
return null
})
const rangeStart = computed(() =>
isSelecting.value ? pendingStart.value : (validRange.value?.start ?? null),
)
const rangeEnd = computed(() =>
isSelecting.value ? null : (validRange.value?.end ?? null),
)
const previewDate = computed(() => (isSelecting.value ? hoverDate.value : null))
const displayValue = computed(() => {
if (isSelecting.value || !validRange.value) return ''
return `${formatIsoToDisplay(validRange.value.start)} - ${formatIsoToDisplay(validRange.value.end)}`
})
const onSelectDay = (iso: string, close: () => void) => {
if (pendingStart.value === null) {
pendingStart.value = iso
hoverDate.value = null
return
}
emit('update:modelValue', normalizeRange(pendingStart.value, iso))
pendingStart.value = null
hoverDate.value = null
close()
}
const onHover = (iso: string | null) => {
if (isSelecting.value) hoverDate.value = iso
}
const onClose = () => {
pendingStart.value = null
hoverDate.value = null
}
const onClear = () => {
emit('update:modelValue', null)
pendingStart.value = null
hoverDate.value = null
}
</script>

View File

@@ -1,122 +0,0 @@
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<DateWeekProps>
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')
})
})

View File

@@ -1,123 +0,0 @@
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validWeek?.monday ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="activeMonday"
:range-end="activeSunday"
:marked-week-start="validWeek?.monday ?? null"
interactive-week-number
:min="min"
:max="max"
@select="(iso) => onSelect(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {formatWeekDisplay, isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek} from './composables/dateWeek'
defineOptions({name: 'MalioDateWeek', inheritAttrs: false})
const props = withDefaults(
defineProps<{
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
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const hoverWeekStart = ref<string | null>(null)
const validWeek = computed(() => {
if (props.modelValue && isValidIsoWeek(props.modelValue)) {
return {monday: isoWeekToMonday(props.modelValue) as string}
}
return null
})
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))
const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))
const onSelect = (iso: string, close: () => void) => {
emit('update:modelValue', toIsoWeek(iso))
hoverWeekStart.value = null
close()
}
const onHover = (iso: string | null) => {
hoverWeekStart.value = iso ? mondayOf(iso) : null
}
const onClose = () => {
hoverWeekStart.value = null
}
const onClear = () => {
emit('update:modelValue', null)
hoverWeekStart.value = null
}
</script>

View File

@@ -1,62 +0,0 @@
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)
})
})
})

View File

@@ -1,26 +0,0 @@
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
}

View File

@@ -1,57 +0,0 @@
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')
})
})
})

View File

@@ -1,31 +0,0 @@
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'
}

View File

@@ -1,74 +0,0 @@
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('')
})
})
})

View File

@@ -1,67 +0,0 @@
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})`
}

View File

@@ -1,64 +0,0 @@
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<typeof useCalendarPopover> = {} as never
const Host = defineComponent({
setup() {
const root = ref<HTMLElement | null>(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)
})
})

View File

@@ -1,28 +0,0 @@
import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue'
export function useCalendarPopover(rootRef: Ref<HTMLElement | null>) {
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}
}

View File

@@ -1,68 +0,0 @@
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)
})
})

View File

@@ -1,51 +0,0 @@
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}
}

View File

@@ -1,69 +0,0 @@
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')
})
})
})

View File

@@ -1,60 +0,0 @@
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<number>,
year: Ref<number>,
): {weeks: ComputedRef<WeekRow[]>} {
const weeks = computed<WeekRow[]>(() => {
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}
}

View File

@@ -1,239 +0,0 @@
<template>
<div>
<div
ref="root"
:class="mergedGroupClass"
>
<input
:id="inputId"
:name="name"
data-test="date-input"
readonly
autocomplete="off"
:class="mergedInputClass"
:required="required"
:disabled="disabled"
:value="displayValue"
:aria-invalid="!!error"
:aria-describedby="describedBy"
:aria-expanded="isOpen"
aria-haspopup="dialog"
v-bind="attrs"
placeholder="_"
type="text"
@click="onFieldClick"
>
<label
v-if="label"
:for="inputId"
:class="mergedLabelClass"
>
{{ label }}
</label>
<div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
<button
v-if="showClear"
type="button"
data-test="clear"
class="text-m-muted hover:text-m-primary"
aria-label="Effacer la date"
@click.stop="emit('clear')"
>
<Icon
icon="mdi:close"
:width="16"
:height="16"
/>
</button>
<Icon
data-test="calendar-icon"
icon="mdi:calendar-blank"
:width="24"
:height="24"
:class="iconStateClass"
/>
</div>
<div
v-if="isOpen"
data-test="popover"
role="dialog"
class="absolute left-0 right-0 top-full z-20 box-border w-full rounded-b-md bg-white p-[10px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
>
<CalendarHeader
:view-mode="viewMode"
:current-month="currentMonth"
:current-year="currentYear"
@prev="goToPrev"
@next="goToNext"
@toggle-view="toggleView"
/>
<slot
v-if="viewMode === 'days'"
:current-month="currentMonth"
:current-year="currentYear"
:close="closePopover"
/>
<MonthPicker
v-else
:selected-month="currentMonth"
@select="onSelectMonth"
/>
</div>
</div>
<p
v-if="hint || hasError || hasSuccess"
:id="`${inputId}-describedby`"
:class="[
hasError ? 'text-m-danger' : hasSuccess ? 'text-m-success' : 'text-m-muted',
'mt-1 ml-[2px] text-xs',
]"
>
{{ error || success || hint }}
</p>
</div>
</template>
<script setup lang="ts">
import {computed, ref, useAttrs, useId, watch} from 'vue'
import {Icon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge'
import CalendarHeader from './CalendarHeader.vue'
import MonthPicker from './MonthPicker.vue'
import {useCalendarPopover} from '../composables/useCalendarPopover'
import {useCalendarView} from '../composables/useCalendarView'
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
const props = withDefaults(
defineProps<{
displayValue: string
syncTo: string | null
id?: string
name?: string
label?: string
placeholder?: string
required?: boolean
disabled?: boolean
readonly?: boolean
hint?: string
error?: string
success?: string
clearable?: boolean
inputClass?: string
labelClass?: string
groupClass?: string
}>(),
{
id: '',
name: '',
label: '',
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
const attrs = useAttrs()
const generatedId = useId()
const root = ref<HTMLElement | null>(null)
const {isOpen, viewMode, open, close: closePopover, toggleView} = useCalendarPopover(root)
const {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso} = useCalendarView(viewMode)
const inputId = computed(() => props.id?.toString() || `malio-date-${generatedId}`)
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !!props.success && !hasError.value)
const isFilled = computed(() => props.displayValue.length > 0)
const showClear = computed(() =>
props.clearable && isFilled.value && !props.disabled && !props.readonly,
)
const describedBy = computed(() =>
(props.hint || hasError.value || hasSuccess.value) ? `${inputId.value}-describedby` : undefined,
)
watch(isOpen, (value) => {
if (!value) emit('close')
})
const onFieldClick = () => {
if (props.disabled || props.readonly) return
if (isOpen.value) {
closePopover()
return
}
syncToIso(props.syncTo)
open()
}
watch(() => props.syncTo, (value) => {
if (isOpen.value) syncToIso(value)
})
const onSelectMonth = (m: number) => {
selectMonth(m)
toggleView()
}
const mergedGroupClass = computed(() =>
twMerge('relative flex h-12 w-full items-center', props.groupClass),
)
const mergedInputClass = computed(() =>
twMerge(
'floating-input peer min-h-[40px] w-full cursor-pointer rounded-md border bg-white py-1 pl-3 pr-10 text-lg outline-none transition-[padding] duration-150 placeholder:text-transparent',
isFilled.value ? 'border-black' : 'border-m-muted',
props.disabled ? 'cursor-not-allowed text-black/60 border-m-muted' : '',
hasError.value
? 'border-m-danger'
: hasSuccess.value
? 'border-m-success'
: 'focus:border-m-primary',
isOpen.value ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
props.inputClass,
),
)
const mergedLabelClass = computed(() =>
twMerge(
'floating-label absolute left-3 top-2 mt-[5px] inline-block origin-left font-medium text-sm transition-transform duration-150',
(isFilled.value || isOpen.value) ? '-translate-y-[1.25rem] scale-90' : '',
hasError.value
? 'text-m-danger'
: hasSuccess.value
? 'text-m-success'
: isOpen.value
? 'text-m-primary'
: 'peer-placeholder-shown:text-m-muted text-black',
props.labelClass,
),
)
const iconStateClass = computed(() => {
if (hasError.value) return 'text-m-danger'
if (hasSuccess.value) return 'text-m-success'
if (isOpen.value) return 'text-m-primary'
if (isFilled.value) return 'text-black'
return 'text-m-muted'
})
</script>
<style scoped>
.floating-label {
background: white;
padding: 0 0.25rem;
}
</style>

View File

@@ -1,70 +0,0 @@
<template>
<div class="flex h-[36px] justify-between border-b border-black/60 mb-3">
<button
type="button"
data-test="header-prev"
class="ml-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois précédent' : 'Année précédente'"
@click="emit('prev')"
>
<Icon
icon="mdi:chevron-left"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-toggle"
class="flex gap-1 rounded text-base font-medium"
@click="emit('toggle-view')"
>
<span class="mt-[2px]">{{ label }}</span>
<Icon
icon="mdi:chevron-down"
:width="25"
:height="25"
/>
</button>
<button
type="button"
data-test="header-next"
class="mr-2 flex self-start rounded"
:aria-label="viewMode === 'days' ? 'Mois suivant' : 'Année suivante'"
@click="emit('next')"
>
<Icon
icon="mdi:chevron-right"
:width="25"
:height="25"
/>
</button>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {Icon} from '@iconify/vue'
defineOptions({name: 'MalioDateCalendarHeader'})
const props = defineProps<{
viewMode: 'days' | 'months'
currentMonth: number
currentYear: number
}>()
const emit = defineEmits<{
(e: 'prev' | 'next' | 'toggle-view'): void
}>()
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const label = computed(() => {
const name = monthsLong[props.currentMonth]
return `${name.charAt(0).toUpperCase()}${name.slice(1)} ${props.currentYear}`
})
</script>

View File

@@ -1,178 +0,0 @@
<template>
<div
data-test="month-grid"
@mouseleave="emit('hover', null)"
>
<div class="grid grid-cols-[auto_repeat(7,minmax(0,1fr))]">
<div class="mr-[12px] flex h-8 w-[35px] items-center justify-center text-[14px] font-medium opacity-[60%]">
S
</div>
<div
v-for="d in dayLabels"
:key="d"
class="flex h-8 items-center justify-center text-[14px] font-medium opacity-[60%]"
>
{{ d }}
</div>
<template
v-for="(week, wIndex) in weeks"
:key="week.days[0].isoDate"
>
<component
:is="interactiveWeekNumber ? 'button' : 'div'"
data-test="week-number"
:data-week-start="week.days[0].isoDate"
:data-marked="markedWeekStart === week.days[0].isoDate"
:type="interactiveWeekNumber ? 'button' : undefined"
:disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
:class="[
weekNumberClass(week),
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
@click="onWeekNumberClick(week)"
@mouseenter="onWeekNumberHover(week)"
>
{{ week.weekNumber }}
</component>
<button
v-for="cell in week.days"
:key="cell.isoDate"
type="button"
data-test="day"
:data-iso="cell.isoDate"
:data-range-role="roleOf(cell)"
:disabled="!inRange(cell.isoDate)"
:aria-label="ariaLabel(cell)"
:aria-disabled="!inRange(cell.isoDate)"
class="relative flex h-[45px] w-full items-center justify-center"
:class="inRange(cell.isoDate) ? 'cursor-pointer' : 'cursor-not-allowed'"
@click="onSelect(cell.isoDate)"
@mouseenter="emit('hover', cell.isoDate)"
>
<span
v-if="roleOf(cell) === 'in-range'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'start'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-l-full bg-m-primary-light"
/>
<span
v-else-if="roleOf(cell) === 'end'"
class="absolute inset-x-0 top-1/2 h-10 -translate-y-1/2 rounded-r-full bg-m-primary-light"
/>
<span
class="relative flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium transition-colors duration-100"
:class="cellClass(cell)"
>
{{ cell.day }}
</span>
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, toRef} from 'vue'
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
import {isDateInRange} from '../composables/dateFormat'
import {dayRangeRole, resolveRangeBounds, type DayRangeRole} from '../composables/dateRange'
defineOptions({name: 'MalioDateMonthGrid'})
const props = withDefaults(
defineProps<{
month: number
year: number
selectedDate?: string | null
rangeStart?: string | null
rangeEnd?: string | null
previewDate?: string | null
interactiveWeekNumber?: boolean
markedWeekStart?: string | null
min?: string
max?: string
}>(),
{
selectedDate: null,
rangeStart: undefined,
rangeEnd: undefined,
previewDate: undefined,
interactiveWeekNumber: false,
markedWeekStart: null,
min: undefined,
max: undefined,
},
)
const emit = defineEmits<{
(e: 'select', iso: string): void
(e: 'hover', iso: string | null): void
}>()
const dayLabels = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const monthsLong = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const {weeks} = useMonthMatrix(toRef(props, 'month'), toRef(props, 'year'))
const inRange = (iso: string) => isDateInRange(iso, props.min, props.max)
const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))
const weekNumberClass = (week: WeekRow) => {
if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
const parts = ['bg-m-primary-light']
parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
return parts.join(' ')
}
const onWeekNumberClick = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('select', week.days[0].isoDate)
}
const onWeekNumberHover = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('hover', week.days[0].isoDate)
}
const isRangeMode = computed(() => props.rangeStart !== undefined)
const bounds = computed(() =>
isRangeMode.value
? resolveRangeBounds(props.rangeStart ?? null, props.rangeEnd ?? null, props.previewDate ?? null)
: null,
)
const roleOf = (cell: DayCell): DayRangeRole => {
if (isRangeMode.value) return dayRangeRole(cell.isoDate, bounds.value)
return props.selectedDate === cell.isoDate ? 'single' : 'none'
}
const ariaLabel = (cell: DayCell) => {
const [, m, d] = cell.isoDate.split('-')
return `${Number(d)} ${monthsLong[Number(m) - 1]} ${cell.isoDate.slice(0, 4)}`
}
const cellClass = (cell: DayCell) => {
if (!inRange(cell.isoDate)) return 'text-m-muted/30'
const role = roleOf(cell)
if (role === 'start' || role === 'end' || role === 'single') return 'bg-m-primary text-white'
if (role === 'in-range') return 'text-black'
const parts = ['hover:bg-m-primary/10']
if (cell.isToday) parts.push('border border-m-primary text-m-primary')
else if (cell.isCurrentMonth) parts.push('text-black')
else parts.push('opacity-[60%]')
return parts.join(' ')
}
const onSelect = (iso: string) => {
if (!inRange(iso)) return
emit('select', iso)
}
</script>

View File

@@ -1,36 +0,0 @@
<template>
<div
data-test="month-picker"
class="grid grid-cols-3 gap-3"
>
<button
v-for="(name, index) in months"
:key="name"
type="button"
data-test="month"
:data-month="index"
class="flex h-[45px] w-full items-center justify-center"
@click="emit('select', index)"
>
<span
class="flex h-[30px] w-full items-center justify-center rounded text-sm transition-colors duration-100"
:class="index === selectedMonth
? 'bg-m-primary text-white'
: 'text-black hover:bg-m-primary/10'"
>
{{ name }}
</span>
</button>
</div>
</template>
<script setup lang="ts">
defineOptions({name: 'MalioDateMonthPicker'})
defineProps<{selectedMonth?: number}>()
const emit = defineEmits<{(e: 'select', month: number): void}>()
const months = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']
</script>

View File

@@ -1,94 +0,0 @@
<template>
<Story title="Date/Date">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDate
v-model="simpleValue"
label="Date de naissance"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDate
v-model="initialValue"
label="Date du jour"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDate
v-model="boundedValue"
label="Date du rendez-vous"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDate
v-model="initialValue"
label="Date verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDate
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
<MalioDate
v-model="initialValue"
label="Lecture seule"
readonly
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDate
v-model="errorValue"
label="Date limite"
error="Date invalide"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Succès</h2>
<MalioDate
v-model="initialValue"
label="Date confirmée"
success="Enregistrée"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDate from '../../components/malio/date/Date.vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>(todayIso)
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
</script>

View File

@@ -1,77 +0,0 @@
<template>
<Story title="Date/DateRange">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateRange
v-model="simpleValue"
label="Période"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateRange
v-model="initialValue"
label="Séjour"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateRange
v-model="boundedValue"
label="Plage bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +30 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDateRange
v-model="initialValue"
label="Période verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateRange
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateRange
v-model="errorValue"
label="Période"
error="Période invalide"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateRange from '../../components/malio/date/DateRange.vue'
type RangeValue = {start: string; end: string}
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
const simpleValue = ref<RangeValue | null>(null)
const initialValue = ref<RangeValue | null>({start: todayIso, end: maxIso})
const boundedValue = ref<RangeValue | null>(null)
const errorValue = ref<RangeValue | null>(null)
</script>

View File

@@ -1,75 +0,0 @@
<template>
<Story title="Date/DateWeek">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateWeek
v-model="simpleValue"
label="Semaine"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateWeek
v-model="initialValue"
label="Semaine de livraison"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateWeek
v-model="boundedValue"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDateWeek
v-model="initialValue"
label="Semaine verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateWeek
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateWeek
v-model="errorValue"
label="Semaine"
error="Semaine invalide"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateWeek from '../../components/malio/date/DateWeek.vue'
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-W21')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
</script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,780 +0,0 @@
# 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 `<MalioDateWeek>` (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 `<script setup lang="ts">`, TypeScript strict, Tailwind (`m-*`), `@iconify/vue`, Vitest + `@vue/test-utils` (jsdom).
**Référence spec :** `docs/superpowers/specs/2026-05-20-dateweek-design.md`
---
## Conventions
- Tests ciblés : `npx vitest run <chemin>` ; commits avec `--no-verify` en cas de hook flaky (suite complète lancée par le hook).
- `data-test` réutilisés (`date-input`, `popover`, `header-*`, `day`+`data-iso`, `clear`, `calendar-icon`, `month`, `week-number`).
- Nouveaux attributs sur la cellule n° de semaine : `data-week-start` (lundi de la ligne), `data-marked` (`"true"`/`"false"`).
- Ordre : 1) `dateWeek.ts` → 2) extension `MonthGrid` → 3) `DateWeek.vue` + tests → 4) story + playground.
---
## Task 1 : Helpers purs `dateWeek.ts`
**Files:**
- Create: `app/components/malio/date/composables/dateWeek.ts`
- Test: `app/components/malio/date/composables/dateWeek.test.ts`
- [ ] **Step 1 : Écrire les tests (échouent)**
```ts
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('')
})
})
})
```
- [ ] **Step 2 : Lancer, vérifier l'échec**`npx vitest run app/components/malio/date/composables/dateWeek.test.ts` → FAIL (import non résolu)
- [ ] **Step 3 : Implémenter**
```ts
// app/components/malio/date/composables/dateWeek.ts
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})`
}
```
- [ ] **Step 4 : Lancer, vérifier le succès** — PASS
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/composables/dateWeek.ts app/components/malio/date/composables/dateWeek.test.ts
git commit -m "feat : helpers purs de semaine ISO (#MUI-33)" --no-verify
```
---
## Task 2 : Ajouts additifs à `MonthGrid.vue`
**Files:**
- Modify: `app/components/malio/date/internal/MonthGrid.vue`
Couvert par `DateWeek.test.ts` (Task 3) ; non-régression par `Date.test.ts` / `DateRange.test.ts`.
- [ ] **Step 1 : Remplacer la cellule n° de semaine** par un élément polymorphe interactif
Dans le template, remplacer le bloc actuel :
```vue
<div
data-test="week-number"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center bg-m-primary-light p-[10px] text-sm"
:class="[
week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60',
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
>
{{ week.weekNumber }}
</div>
```
par :
```vue
<component
:is="interactiveWeekNumber ? 'button' : 'div'"
data-test="week-number"
:data-week-start="week.days[0].isoDate"
:data-marked="markedWeekStart === week.days[0].isoDate"
:type="interactiveWeekNumber ? 'button' : undefined"
:disabled="interactiveWeekNumber ? !weekSelectable(week) : undefined"
class="mr-[12px] flex h-[45px] w-[35px] shrink-0 items-center justify-center p-[10px] text-sm"
:class="[
weekNumberClass(week),
wIndex === 0 ? 'rounded-t-md' : '',
wIndex === weeks.length - 1 ? 'rounded-b-md' : '',
]"
@click="onWeekNumberClick(week)"
@mouseenter="onWeekNumberHover(week)"
>
{{ week.weekNumber }}
</component>
```
- [ ] **Step 2 : Ajouter les props et la logique** (script)
Modifier les `defineProps`/`withDefaults` pour ajouter `interactiveWeekNumber` et `markedWeekStart`, importer `WeekRow`, et ajouter les helpers. Remplacer le bloc props :
```ts
const props = withDefaults(
defineProps<{
month: number
year: number
selectedDate?: string | null
rangeStart?: string | null
rangeEnd?: string | null
previewDate?: string | null
interactiveWeekNumber?: boolean
markedWeekStart?: string | null
min?: string
max?: string
}>(),
{
selectedDate: null,
rangeStart: undefined,
rangeEnd: undefined,
previewDate: undefined,
interactiveWeekNumber: false,
markedWeekStart: null,
min: undefined,
max: undefined,
},
)
```
Mettre à jour l'import du composable pour récupérer `WeekRow` :
```ts
import {useMonthMatrix, type DayCell, type WeekRow} from '../composables/useMonthMatrix'
```
Ajouter, après `const inRange = ...` :
```ts
const weekSelectable = (week: WeekRow) => week.days.some(d => inRange(d.isoDate))
const weekNumberClass = (week: WeekRow) => {
if (props.markedWeekStart === week.days[0].isoDate) return 'bg-m-primary text-white'
const parts = ['bg-m-primary-light']
parts.push(week.days.some(d => d.isToday) ? 'text-black' : 'text-black/60')
if (props.interactiveWeekNumber && weekSelectable(week)) parts.push('cursor-pointer')
return parts.join(' ')
}
const onWeekNumberClick = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('select', week.days[0].isoDate)
}
const onWeekNumberHover = (week: WeekRow) => {
if (!props.interactiveWeekNumber || !weekSelectable(week)) return
emit('hover', week.days[0].isoDate)
}
```
- [ ] **Step 3 : Non-régression `Date` et `DateRange`**
Run: `npx vitest run app/components/malio/date/Date.test.ts app/components/malio/date/DateRange.test.ts`
Expected: PASS (21 + 17). La cellule n° reste un `<div>` non interactif quand `interactiveWeekNumber` est `false`.
- [ ] **Step 4 : Lint**`npx eslint app/components/malio/date/internal/MonthGrid.vue` → 0 erreur
- [ ] **Step 5 : Commit**
```bash
git add app/components/malio/date/internal/MonthGrid.vue
git commit -m "feat : MonthGrid n° de semaine interactif + repère (mode semaine) (#MUI-33)" --no-verify
```
---
## Task 3 : `DateWeek.vue` + tests
**Files:**
- Create: `app/components/malio/date/DateWeek.vue`
- Test: `app/components/malio/date/DateWeek.test.ts`
- [ ] **Step 1 : Créer `DateWeek.vue`**
```vue
<!-- app/components/malio/date/DateWeek.vue -->
<template>
<CalendarField
:id="id"
:display-value="displayValue"
:sync-to="validWeek?.monday ?? null"
:name="name"
:label="label"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:hint="hint"
:error="error"
:success="success"
:clearable="clearable"
:input-class="inputClass"
:label-class="labelClass"
:group-class="groupClass"
v-bind="$attrs"
@clear="onClear"
@close="onClose"
>
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid
:month="currentMonth"
:year="currentYear"
:range-start="activeMonday"
:range-end="activeSunday"
:marked-week-start="validWeek?.monday ?? null"
interactive-week-number
:min="min"
:max="max"
@select="(iso) => onSelect(iso, close)"
@hover="onHover"
/>
</template>
</CalendarField>
</template>
<script setup lang="ts">
import {computed, ref} from 'vue'
import CalendarField from './internal/CalendarField.vue'
import MonthGrid from './internal/MonthGrid.vue'
import {isValidIsoWeek, isoWeekToMonday, mondayOf, sundayOf, toIsoWeek, formatWeekDisplay} from './composables/dateWeek'
defineOptions({name: 'MalioDateWeek', inheritAttrs: false})
const props = withDefaults(
defineProps<{
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
}>(),
{
id: '',
name: '',
label: '',
modelValue: undefined,
placeholder: 'JJ/MM/AAAA',
required: false,
disabled: false,
readonly: false,
hint: '',
error: '',
success: '',
min: undefined,
max: undefined,
clearable: true,
inputClass: '',
labelClass: '',
groupClass: '',
},
)
const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>()
const hoverWeekStart = ref<string | null>(null)
const validWeek = computed(() => {
if (props.modelValue && isValidIsoWeek(props.modelValue)) {
return {monday: isoWeekToMonday(props.modelValue) as string}
}
return null
})
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
const activeSunday = computed(() => (activeMonday.value ? sundayOf(activeMonday.value) : null))
const displayValue = computed(() => (validWeek.value ? formatWeekDisplay(props.modelValue as string) : ''))
const onSelect = (iso: string, close: () => void) => {
emit('update:modelValue', toIsoWeek(iso))
hoverWeekStart.value = null
close()
}
const onHover = (iso: string | null) => {
hoverWeekStart.value = iso ? mondayOf(iso) : null
}
const onClose = () => {
hoverWeekStart.value = null
}
const onClear = () => {
emit('update:modelValue', null)
hoverWeekStart.value = null
}
</script>
```
- [ ] **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<DateWeekProps>
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
<!-- app/story/date/dateWeek.story.vue -->
<template>
<Story title="Date/DateWeek">
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Simple</h2>
<MalioDateWeek
v-model="simpleValue"
label="Semaine"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
<MalioDateWeek
v-model="initialValue"
label="Semaine de livraison"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
<MalioDateWeek
v-model="boundedValue"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
<MalioDateWeek
v-model="initialValue"
label="Semaine verrouillée"
:clearable="false"
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
<MalioDateWeek
v-model="initialValue"
label="Désactivé"
disabled
/>
</div>
<div class="rounded-lg border p-4">
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
<MalioDateWeek
v-model="errorValue"
label="Semaine"
error="Semaine invalide"
/>
</div>
</div>
</Story>
</template>
<script setup lang="ts">
import {ref} from 'vue'
import MalioDateWeek from '../../components/malio/date/DateWeek.vue'
const simpleValue = ref<string | null>(null)
const initialValue = ref<string | null>('2026-W21')
const boundedValue = ref<string | null>(null)
const errorValue = ref<string | null>(null)
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
</script>
```
- [ ] **Step 2 : Créer la page playground**
```vue
<!-- .playground/pages/composant/date/dateWeek.vue -->
<template>
<div class="space-y-6 p-4">
<h1 class="text-2xl font-bold">MalioDateWeek</h1>
<div class="flex flex-wrap items-start gap-10">
<div class="w-[480px] space-y-3">
<h2 class="font-semibold">Large (480px)</h2>
<MalioDateWeek
v-model="value"
label="Semaine"
hint="Clique un jour ou un n° de semaine"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ value ?? 'null' }}</code></p>
</div>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-m-primary px-3 py-1.5 text-white"
@click="value = '2026-W52'"
>
Forcer 2026-W52
</button>
<button
type="button"
class="rounded border px-3 py-1.5"
@click="value = null"
>
Réinitialiser
</button>
</div>
</div>
<div class="w-[396px] space-y-3">
<h2 class="font-semibold">ERP (396px)</h2>
<MalioDateWeek
v-model="erpValue"
label="Semaine"
hint="Largeur cible ERP"
/>
<div class="rounded border p-3 text-sm">
<p>Valeur : <code>{{ erpValue ?? 'null' }}</code></p>
</div>
<MalioDateWeek
v-model="bounded"
label="Semaine bornée"
:min="todayIso"
:max="maxIso"
hint="Entre aujourd'hui et +60 jours"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue'
const pad = (n: number) => String(n).padStart(2, '0')
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const now = new Date()
const todayIso = toIso(now)
const maxIso = toIso(new Date(now.getTime() + 60 * 86400000))
const value = ref<string | null>(null)
const erpValue = ref<string | null>(null)
const bounded = ref<string | null>(null)
</script>
```
- [ ] **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.

View File

@@ -1,373 +0,0 @@
# 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 `<MalioDate>` (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 `<MalioTime>` 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 `<form>` |
| `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<number>,
year: Ref<number>
): { weeks: ComputedRef<WeekRow[]> }
```
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<HTMLElement | null>): {
isOpen: Ref<boolean>
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 `<MalioInputAutocomplete>` : 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 `<label for>` lié au champ
- Cellules jour : `role="button"`, `aria-label="19 mai 2026"` (jour en toutes lettres pour les lecteurs d'écran)
- Cellules désactivées : `aria-disabled="true"`
- Navigation clavier dans la grille : **reportée v2** (Escape, flèches, Enter)
## Tests
### `Date.test.ts` (~30 cas)
Tests groupés par `describe` :
- **Rendu** : label, placeholder, icône calendrier, affichage de la valeur formatée
- **Popover** : ouverture au clic, fermeture au click outside, vue initiale selon `modelValue`
- **Navigation** : chevrons en vue jours, passage décembre↔janvier avec changement d'année
- **Sélection** : émission ISO correcte, fermeture après sélection, sélection d'un jour hors mois
- **Bornes** : jours hors `min`/`max` non cliquables, comparaison ISO
- **Vue mois** : bascule, chevrons en vue mois naviguent l'année, clic mois retourne en vue jours
- **Clearable** : présence/absence de la croix, émission `null`, pas d'ouverture
- **États** : `disabled` et `readonly` bloquent l'ouverture
- **A11y** : `aria-invalid`, `aria-describedby`
- **Synchro externe** : changement de `modelValue` programmatique
### `useMonthMatrix.test.ts` (~10 cas)
- Mois standard (mai 2026) produit 6×7 cellules
- Mois commençant un lundi (toutes les cases du premier lundi sont du mois courant)
- Mois finissant un dimanche
- Année bissextile (février 2024 : 29 jours)
- Numéro de semaine ISO en début d'année (janvier 2026 commence en semaine 1 ou 52/53 de 2025 ?)
- Numéro de semaine ISO en fin d'année
### `dateFormat.test.ts` (~10 cas)
- `formatIsoToDisplay` : nominal, null, format invalide
- `parseDisplayToIso` : nominal, format invalide, jour ou mois hors borne
- `isValidIso` : nominal, faux positifs (32 jours, mois 13)
- `isDateInRange` : sans bornes, avec min seul, avec max seul, avec les deux
Helper `mountComponent(props)` reprend le pattern existant des autres tests Malio. Environnement Vitest + jsdom (déjà configurés).
## Story Histoire `Date.story.vue`
Dans `app/story/date/Date.story.vue`. Variants :
1. **Default** — vierge, label "Date de naissance"
2. **Avec valeur initiale**`modelValue="2026-05-19"`
3. **Avec min/max** — borné aujourd'hui → +30 jours, label "Date du rendez-vous"
4. **États** — disabled, readonly, error, success, hint
5. **Non-clearable**`clearable=false`
6. **Required** — avec error si vide
7. **Override de classes**`inputClass`, `groupClass` custom
## Playground `.playground/pages/composant/date.vue`
Page de test dev :
- Un `<MalioDate>` standalone
- Affichage de la valeur courante en dessous
- Boutons pour reset (`value = null`) et forcer une date (`value = '2026-12-25'`)
- Un cas avec `min`/`max`
Lien ajouté dans `.playground/pages/index.vue`.
## Découpage de l'implémentation
Le plan d'implémentation (généré ensuite via `writing-plans`) découpera en étapes ordonnées :
1. Composables purs (`dateFormat`, `useMonthMatrix`, `useCalendarPopover`) + leurs tests
2. Sous-composants internes (`CalendarHeader`, `MonthGrid`, `MonthPicker`)
3. Composant public `Date.vue`
4. Tests d'intégration `Date.test.ts`
5. Story Histoire + page playground

View File

@@ -1,243 +0,0 @@
# MalioDateRange — Design Spec
Composant de sélection d'une **période** (date début / date fin) via un champ + popover calendrier. Deuxième brique de la famille temporelle, construite sur un shell partagé extrait de `MalioDate`.
**Ticket :** MUI-33 (suite)
**Branche :** `feature/MUI-33-developper-le-composant-datepicker`
**Spec liée :** `docs/superpowers/specs/2026-05-19-datepicker-design.md`
## Contexte & roadmap
`MalioDate` (sélection simple) est déjà livré. Suivront `DateWeek` et `DateTime`. Les 4 composants partagent le **même champ + popover + header + vue mois** ; seule la sélection diffère. On extrait donc un shell réutilisable `CalendarField` (Approche 3 retenue parce que la famille comptera 4 variantes — la duplication de coquille ×4 et la maintenance front en parallèle sont le vrai risque).
## Périmètre
Sélection d'une période sur **un seul mois** affiché (popover = largeur du champ, comme `Date`). Visuellement identique à `Date`, sauf :
- on sélectionne **deux dates** (début/fin)
- les jours **entre** les bornes ont un fond `bg-m-primary-light` (bleu clair)
- les bornes (start/end) gardent le cercle plein `bg-m-primary`
- **aperçu au survol** (hover preview) de la plage pendant la sélection
**Inclus :** sélection 2 clics, auto-inversion, hover preview, surlignage de plage (demi-barre aux bornes), bornes `min`/`max`, effacement, vue mois conservée.
**Reporté :** deux mois côte à côte, ajustement de la borne la plus proche au 3e clic (on garde le reset standard), saisie clavier, navigation clavier.
## Architecture (Approche 3 — shell partagé)
```
app/components/malio/date/
Date.vue # ENVELOPPE (refacto) — sélection simple
DateRange.vue # ENVELOPPE (nouveau) — sélection période
Date.test.ts # inchangé (filet de sécurité du refacto)
DateRange.test.ts # nouveau
internal/
CalendarField.vue # NOUVEAU — shell : champ + popover + header + MonthPicker
CalendarHeader.vue # inchangé
MonthGrid.vue # étendu : props range + émission hover + data-range-role
MonthPicker.vue # inchangé
composables/
useCalendarView.ts # NOUVEAU — état mois/année + navigation (extrait de Date.vue)
useCalendarView.test.ts
useCalendarPopover.ts # inchangé
dateRange.ts # NOUVEAU — helpers purs de plage
dateRange.test.ts
dateFormat.ts # inchangé
useMonthMatrix.ts # inchangé
app/story/date/dateRange.story.vue
.playground/pages/composant/date/dateRange.vue
```
Flux :
```
DateRange.vue (enveloppe)
├─ état de sélection range ({start,end} + pendingStart + hoverDate)
├─ displayValue ("19/05/2026 - 25/05/2026")
└─ <CalendarField :display-value :sync-to=start ... @clear @close>
├─ champ + popover (useCalendarPopover) + navigation (useCalendarView)
├─ header + MonthPicker (viewMode='months')
└─ <slot :current-month :current-year :close> ← viewMode='days'
└─ <MonthGrid> mode range (rangeStart/rangeEnd/previewDate, @select, @hover)
```
`CalendarField` ne connaît **rien** de la sélection : il gère champ, ouverture, navigation, et expose `{ currentMonth, currentYear, close }` au slot. Chaque enveloppe branche son `MonthGrid` et décide quand appeler `close()`.
## `useCalendarView.ts`
```ts
function useCalendarView(viewMode: Ref<'days' | 'months'>): {
currentMonth: Ref<number> // 0-11
currentYear: Ref<number>
goToPrev: () => void // viewMode==='months' ? année-1 : mois-1 (roulement déc↔jan)
goToNext: () => void // idem +1
selectMonth: (m: number) => void // currentMonth = m
syncToIso: (iso: string | null) => void // mois/année depuis un ISO valide, sinon mois courant
}
```
Reprend la logique `onPrev`/`onNext`/`syncViewToValue` actuelle de `Date.vue`. Pur, testable seul.
## `CalendarField.vue` (shell)
**Props :**
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `displayValue` | `string` | **requis** | Texte affiché dans le champ (`''` si rien/incomplet) |
| `syncTo` | `string \| null` | **requis** | ISO servant à caler le mois à l'ouverture |
| `id`,`name`,`label`,`placeholder` | `string` | `''` / `'JJ/MM/AAAA'` | Champ |
| `required`,`disabled`,`readonly` | `boolean` | `false` | États |
| `hint`,`error`,`success` | `string` | `''` | Messages |
| `clearable` | `boolean` | `true` | Croix d'effacement |
| `inputClass`,`labelClass`,`groupClass` | `string` | `''` | Overrides twMerge |
**Events :**
| Event | Payload | Description |
|-------|---------|-------------|
| `clear` | — | Croix cliquée → l'enveloppe met son `modelValue` à `null` |
| `close` | — | Popover fermé (clic dehors ou programmatique) → l'enveloppe annule sa sélection en cours |
**Slot par défaut** (scoped, rendu quand `viewMode==='days'`) : `{ currentMonth: number, currentYear: number, close: () => void }`.
**Comportement** (repris à l'identique de l'actuel `Date.vue`) : input readonly, label flottant (bleu à l'ouverture), icône calendrier, croix (si `clearable && displayValue && !disabled && !readonly`), grossissement calibré 48px, bordures/états, popover ombré largeur champ collé sous le champ, header (`prev`/`next``useCalendarView`, `toggle``useCalendarPopover.toggleView`), `MonthPicker` en vue mois (clic mois → `selectMonth` + retour vue jours), `syncToIso(syncTo)` à l'ouverture + watch resync. `isFilled` dérivé de `displayValue.length > 0`.
## `dateRange.ts` (helpers purs)
```ts
type DateRangeValue = { start: string; end: string }
function normalizeRange(a: string, b: string): DateRangeValue
// réordonne pour garantir start ≤ end
function resolveRangeBounds(
start: string | null, end: string | null, preview: string | null,
): { lo: string; hi: string } | null
// pas de start → null ; end committé prioritaire, sinon preview, sinon {lo:start,hi:start}
type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
function dayRangeRole(iso: string, bounds: { lo: string; hi: string } | null): DayRangeRole
```
## `MonthGrid.vue` — extension
**Nouvelles props (optionnelles) :** `rangeStart?`, `rangeEnd?`, `previewDate?` (ISO ou null). Mode plage actif dès que `rangeStart` est passé ; sinon mode simple (`selectedDate`, comportement actuel inchangé).
**Nouvel event :** `hover` payload `string | null``mouseenter` d'un jour → ISO, `mouseleave` de la grille → `null`.
**Attribut testabilité :** chaque bouton jour porte `:data-range-role="role"` (`none`/`single`/`start`/`end`/`in-range`).
**Rendu d'un jour en mode plage** — bouton `relative` superposant 2 couches :
1. Barre de fond absolue `bg-m-primary-light` : `in-range` → pleine largeur (`inset-0`) ; `start` → moitié droite (`left-1/2 right-0`) ; `end` → moitié gauche (`left-0 right-1/2`) ; `single`/`none` → aucune.
2. Cercle (span `h-10 w-10`) au-dessus : `start`/`end`/`single``bg-m-primary` blanc ; `in-range` → transparent, texte noir ; `none` → rendu simple actuel (aujourd'hui, hors-mois…).
La barre passe sous les cercles, colonnes jointives → plage continue démarrant/finissant au centre des cercles.
## `DateRange.vue` (enveloppe)
**Props :** identiques à `Date` sauf `modelValue?: { start: string; end: string } | null`. (`id`,`name`,`label`,`placeholder`,`required`,`disabled`,`readonly`,`hint`,`error`,`success`,`min`,`max`,`clearable`,`inputClass`,`labelClass`,`groupClass`.)
**Emit :** `update:modelValue``{ start: string; end: string } | null`.
**État interne :**
```ts
pendingStart = ref<string | null>(null) // 1er clic en attente du 2e
hoverDate = ref<string | null>(null) // survol pour le preview
const isSelecting = computed(() => pendingStart.value !== null)
```
**Passé au `<MonthGrid>` :**
```ts
rangeStart = isSelecting ? pendingStart : (modelValue?.start ?? null)
rangeEnd = isSelecting ? null : (modelValue?.end ?? null)
previewDate = isSelecting ? hoverDate : null
// + :min :max :month :year (slot)
```
**`displayValue` :** `''` pendant la sélection (1 seul jour choisi) ; `"JJ/MM/AAAA - JJ/MM/AAAA"` si plage complète ; `''` sinon. **`syncTo`** = `modelValue?.start ?? null`.
**Machine à états :**
```
onSelectDay(iso):
si pendingStart === null: # 1er clic (ou reset après plage complète)
pendingStart = iso ; hoverDate = null
sinon: # 2e clic → complète
{ start, end } = normalizeRange(pendingStart, iso) # auto-inversion
emit('update:modelValue', { start, end })
pendingStart = null ; hoverDate = null
close() # ferme le popover (slot)
onHover(iso): # émis par MonthGrid
si isSelecting: hoverDate = iso # preview seulement pendant la sélection
onClose(): # CalendarField émet 'close'
pendingStart = null ; hoverDate = null # annule la sélection en cours, modelValue inchangé
onClear(): # CalendarField émet 'clear'
emit('update:modelValue', null)
pendingStart = null ; hoverDate = null
```
- **3e clic** (plage complète) : `pendingStart===null` → nouveau `start`, ancienne plage masquée pendant la sélection (`rangeEnd=null`), remplacée à la complétion.
- **min/max** : `MonthGrid` désactive les jours hors bornes → les 2 clics sont contraints.
- **modelValue invalide** (start/end mal formés) : traité comme `null` + warning dev.
## Refacto `Date.vue`
API publique **inchangée**. Devient une enveloppe (~80 lignes) :
```vue
<CalendarField :display-value="displayValue" :sync-to="modelValue ?? null" ...props
@clear="emit('update:modelValue', null)">
<template #default="{ currentMonth, currentYear, close }">
<MonthGrid :month="currentMonth" :year="currentYear"
:selected-date="modelValue ?? null" :min="min" :max="max"
@select="(iso) => { emit('update:modelValue', iso); close() }" />
</template>
</CalendarField>
```
`displayValue = formatIsoToDisplay(modelValue)`. Watch modelValue invalide → warning dev (conservé). Mode simple : pas de `@close` (rien à annuler), pas de `@hover`.
**Les 21 tests de `Date.test.ts` doivent passer sans modification** : tous les `data-test` sont rendus par `CalendarField`/`MonthGrid`, donc présents dans le DOM monté de `Date`. C'est le filet de sécurité du refacto.
## Tests
### `dateRange.test.ts` (~12)
- `normalizeRange` : ordonné, inversé, égal
- `resolveRangeBounds` : pas de start → null ; start seul → `{lo,hi}=start` ; start+end ordonné ; start+preview ; preview avant start (inversion) ; end prioritaire sur preview
- `dayRangeRole` : none (pas de bornes / hors plage), single (lo===hi), start, end, in-range
### `useCalendarView.test.ts` (~8, fake timers)
- mois/année initiaux = aujourd'hui ; `goToNext`/`goToPrev` vue jours (+ roulement déc↔jan avec année) ; `goToNext`/`goToPrev` vue mois (année ±1) ; `selectMonth` ; `syncToIso` valide / null
### `Date.test.ts`
Inchangé — doit rester vert (filet du refacto).
### `DateRange.test.ts` (~18)
- Rendu : label, icône, `"19/05/2026 - 25/05/2026"` si modelValue, champ vide sinon
- Ouverture popover, vue sur le mois du `start`
- 1er clic → pas d'émission ; 2e clic → émet `{start,end}` + ferme
- 2e clic avant le 1er → auto-inversion (start ≤ end)
- Même jour ×2 → `{start:x, end:x}`
- 3e clic → repart sur un nouveau start (pas d'émission avant le 2e)
- Hover pendant sélection → `data-range-role="in-range"` sur jours intermédiaires ; pas de preview hors sélection
- Rôles : `start`/`end`/`in-range` corrects via `data-range-role`
- Clic dehors pendant sélection → annulation, `modelValue` inchangé
- `clear` → émet `null`
- min/max → jours hors bornes non cliquables
- a11y : `aria-invalid` sur error
### Story `dateRange.story.vue`
Default vide, plage initiale, min/max, états (disabled/readonly/error/success), non-clearable.
### Playground `.playground/pages/composant/date/dateRange.vue`
`<MalioDateRange>` standalone + affichage `start → end`, boutons set/reset, cas borné.
## Découpage d'implémentation
1. Helpers purs `dateRange.ts` + tests
2. Composable `useCalendarView.ts` + tests
3. Shell `CalendarField.vue` (extraction depuis `Date.vue`)
4. Refacto `Date.vue` en enveloppe → `Date.test.ts` doit rester vert
5. Extension `MonthGrid.vue` (range + hover + data-range-role)
6. `DateRange.vue` + `DateRange.test.ts`
7. Story + playground

View File

@@ -1,168 +0,0 @@
# MalioDateWeek — Design Spec
Composant de sélection d'une **semaine ISO complète** (lundi→dimanche) via le shell calendrier partagé. Troisième brique de la famille temporelle.
**Ticket :** MUI-33 (suite)
**Branche :** `feature/MUI-33-developper-le-composant-datepicker`
**Specs liées :** `2026-05-19-datepicker-design.md`, `2026-05-20-daterange-design.md`
## Périmètre
Sélection d'une semaine entière en **un clic** (sur n'importe quel jour OU le numéro de semaine). Visuellement : la ligne de la semaine se surligne en pilule `bg-m-primary-light` (lundi arrondi gauche, dimanche arrondi droit), exactement comme une plage `DateRange` figée lun→dim. Survol = aperçu de la semaine. La cellule n° de la semaine sélectionnée passe en `bg-m-primary` (repère).
**Inclus :** clic jour/n° → semaine, hover de semaine, surlignage pilule, repère n° semaine, bornes `min`/`max` (semaine sélectionnable si elle chevauche), effacement, vue mois conservée.
**Reporté :** deux mois, saisie/navigation clavier.
## Donnée retournée
`modelValue: string | null` au format **ISO 8601 semaine `YYYY-Www`** (ex. `"2026-W21"`), comme l'`<input type="week">` natif. L'année est l'**année ISO de numérotation** (peut différer de l'année calendaire aux bords d'année). Affichage humain dans le champ : `"Semaine 21 (18/05 → 24/05/2026)"` (le `modelValue` reste `2026-W21`).
## Architecture (Approche 1 — réutilisation du rendu plage)
Une semaine sélectionnée **est** une plage lundi→dimanche : on réutilise le rendu pilule de `MonthGrid` (mode plage) en passant les bornes de la semaine active. Les events `select`/`hover` (jour) sont réutilisés ; l'enveloppe `DateWeek` mappe jour → semaine.
```
app/components/malio/date/
DateWeek.vue # NOUVEAU — enveloppe
DateWeek.test.ts # nouveau
internal/
MonthGrid.vue # étendu : interactiveWeekNumber + markedWeekStart (additifs)
CalendarField.vue # inchangé (shell réutilisé)
CalendarHeader.vue # inchangé
MonthPicker.vue # inchangé
composables/
dateWeek.ts # NOUVEAU — helpers semaine ISO (purs)
dateWeek.test.ts # nouveau
dateRange.ts # inchangé (rendu pilule réutilisé)
dateFormat.ts # inchangé
useCalendarView.ts # inchangé
useCalendarPopover.ts # inchangé
useMonthMatrix.ts # inchangé
app/story/date/dateWeek.story.vue
.playground/pages/composant/date/dateWeek.vue
```
Flux :
```
DateWeek.vue (enveloppe)
├─ état : hoverWeekStart (lundi de la semaine survolée)
├─ validWeek = isValidIsoWeek(modelValue) ? { monday: isoWeekToMonday(modelValue) } : null
├─ activeMonday = hoverWeekStart ?? validWeek.monday → activeSunday = sundayOf(activeMonday)
├─ displayValue = formatWeekDisplay(modelValue)
└─ <CalendarField :display-value :sync-to=validWeek.monday @clear @close>
└─ <MonthGrid
:range-start=activeMonday :range-end=activeSunday ← pilule lun→dim réutilisée
:marked-week-start=validWeek.monday ← repère n° semaine
interactive-week-number ← n° semaine cliquable/hoverable
:min :max
@select="(iso)=>onSelect(iso, close)" @hover="onHover" />
```
## `dateWeek.ts` (helpers purs)
```ts
function mondayOf(iso: string): string // "2026-05-20" → "2026-05-18"
function sundayOf(iso: string): string // "2026-05-20" → "2026-05-24"
function toIsoWeek(iso: string): string // "2026-05-20" → "2026-W21" (année ISO + n° semaine)
function isoWeekToMonday(week: string): string | null // "2026-W21" → "2026-05-18" ; invalide → null
function isValidIsoWeek(week: string): boolean // "2026-W21" → true ; "2026-W54"/"2026-21" → false
function formatWeekDisplay(week: string): string // "2026-W21" → "Semaine 21 (18/05 → 24/05/2026)" ; invalide → ""
```
- Algo ISO 8601 : jeudi de la semaine pour l'année de numérotation ; lundi de la semaine contenant le 4 janvier = semaine 1.
- `formatWeekDisplay` : `Semaine {n° sans zéro} ({JJ/MM lundi} → {JJ/MM/AAAA dimanche})`, réutilise `formatIsoToDisplay`.
- Cas pièges testés : `2025-12-31``2026-W01`, `2027-01-01``2026-W53`, `2026-01-01``2026-W01`.
## `MonthGrid.vue` — ajouts additifs
Nouvelles props optionnelles (n'altèrent pas les modes simple/plage) :
```ts
interactiveWeekNumber?: boolean // défaut false
markedWeekStart?: string | null // défaut null — lundi de la semaine repère
```
Quand `interactiveWeekNumber === true` :
- La cellule n° de semaine devient un `<button>` : `@click` émet `select(week.days[0].isoDate)`, `@mouseenter` émet `hover(week.days[0].isoDate)`. `:disabled` si `!weekSelectable`, où `weekSelectable = week.days.some(d => inRange(d.isoDate))`. `cursor-pointer` si sélectionnable.
- Repère : si `week.days[0].isoDate === markedWeekStart`, la cellule n° passe en `bg-m-primary text-white` (au lieu de `bg-m-primary-light`).
Toujours (inoffensif hors mode semaine) : la cellule n° porte `:data-week-start="week.days[0].isoDate"` et `:data-marked="week.days[0].isoDate === markedWeekStart"`.
Inchangé : rendu pilule des jours piloté par `rangeStart`/`rangeEnd` ; events `select`/`hover` jour ; quand `interactiveWeekNumber` est `false`, la cellule n° reste un `<div>` non cliquable (aucune régression `Date`/`DateRange`).
## `DateWeek.vue` (enveloppe)
**Props :** identiques à `Date` sauf `modelValue?: string | null` (`YYYY-Www`).
**Emit :** `update:modelValue``string | null`.
**État :**
```ts
hoverWeekStart = ref<string | null>(null)
const validWeek = computed(() =>
(props.modelValue && isValidIsoWeek(props.modelValue))
? {monday: isoWeekToMonday(props.modelValue) as string}
: null)
const activeMonday = computed(() => hoverWeekStart.value ?? validWeek.value?.monday ?? null)
const activeSunday = computed(() => activeMonday.value ? sundayOf(activeMonday.value) : null)
```
**Passé à `MonthGrid` :** `range-start=activeMonday`, `range-end=activeSunday`, `marked-week-start=validWeek?.monday ?? null`, `interactive-week-number`, `min`, `max`, month/year du slot.
**`displayValue`** = `validWeek ? formatWeekDisplay(modelValue) : ''`. **`syncTo`** = `validWeek?.monday ?? null`.
**Comportement (1 clic) :**
```
onSelect(iso, close): # jour OU n° de semaine (= lundi)
emit('update:modelValue', toIsoWeek(iso))
hoverWeekStart = null
close()
onHover(iso): # jour/n° survolé ; null au mouseleave
hoverWeekStart = iso ? mondayOf(iso) : null
onClose(): hoverWeekStart = null
onClear(): emit('update:modelValue', null) ; hoverWeekStart = null
```
- Survol → toute la ligne en pilule via `activeMonday`.
- Sélection en un clic → `YYYY-Www` + fermeture.
- Repère de la semaine committée conservé pendant le survol d'une autre.
- `modelValue` invalide → traité comme `null` + warning dev.
## Tests
### `dateWeek.test.ts` (~14)
- `mondayOf`/`sundayOf` : mercredi, lundi (idempotent), dimanche
- `toIsoWeek` : nominal + bords d'année (`2025-12-31``2026-W01`, `2027-01-01``2026-W53`, `2026-01-01``2026-W01`)
- `isoWeekToMonday` : `2026-W21``2026-05-18` ; round-trip ; invalide → null
- `isValidIsoWeek` : valide / `W00` / `W54` / format faux
- `formatWeekDisplay` : `2026-W21``"Semaine 21 (18/05 → 24/05/2026)"` ; invalide → `""`
### `DateWeek.test.ts` (~14, `setSystemTime(2026-05-19)`)
- Rendu label/icône, affichage `"Semaine ..."` si modelValue, champ vide sinon
- Ouverture sur le mois de la semaine du modelValue
- Clic d'un jour → émet le `YYYY-Www` de sa semaine + ferme
- Clic du n° de semaine (`[data-test="week-number"][data-week-start="..."]`) → émet + ferme
- Hover d'un jour → `data-range-role` start/in-range/end sur les 7 jours de la ligne ; autre ligne `none`
- Hover du n° de semaine → même surlignage
- Semaine committée → roles corrects + `data-marked="true"` sur la cellule n°
- `clear` → émet `null`
- min/max : semaine hors bornes n° désactivé + jours non cliquables ; semaine qui chevauche reste sélectionnable
- `disabled`/`readonly` → pas d'ouverture
- a11y : `aria-invalid` sur error
`Date.test.ts` / `DateRange.test.ts` restent verts (props additives).
### Story `dateWeek.story.vue`
Default vide, semaine initiale, min/max, états (disabled/readonly/error/success), non-clearable.
### Playground `.playground/pages/composant/date/dateWeek.vue`
Comparatif Large (480px) / ERP (396px), affichage `modelValue` + bornes du champ, boutons set/reset, cas borné.
## Découpage d'implémentation
1. `dateWeek.ts` (purs) + tests
2. Extension `MonthGrid.vue` (interactiveWeekNumber + markedWeekStart + data attrs)
3. `DateWeek.vue` + `DateWeek.test.ts`
4. Story + playground

View File

@@ -20,7 +20,6 @@ export default {
colors: {
m: {
primary: 'rgb(var(--m-primary) / <alpha-value>)',
'primary-light': 'rgb(var(--m-primary-light) / <alpha-value>)',
surface: 'rgb(var(--m-surface) / <alpha-value>)',
border: 'rgb(var(--m-border) / <alpha-value>)',
text: 'rgb(var(--m-text) / <alpha-value>)',