feat : composant MalioDateWeek (sélection semaine ISO) (#MUI-33)
This commit is contained in:
122
app/components/malio/date/DateWeek.test.ts
Normal file
122
app/components/malio/date/DateWeek.test.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
import DateWeek from './DateWeek.vue'
|
||||||
|
|
||||||
|
type DateWeekProps = {
|
||||||
|
modelValue?: string | null
|
||||||
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
error?: string
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DateWeekForTest = DateWeek as DefineComponent<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')
|
||||||
|
})
|
||||||
|
})
|
||||||
123
app/components/malio/date/DateWeek.vue
Normal file
123
app/components/malio/date/DateWeek.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<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>
|
||||||
Reference in New Issue
Block a user