[#MUI-33] Développer le composant Datepicker (#50)
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #50 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #50.
This commit is contained in:
198
app/components/malio/date/Date.test.ts
Normal file
198
app/components/malio/date/Date.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import Date_ from './Date.vue'
|
||||
|
||||
type DateProps = {
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}
|
||||
|
||||
const DateForTest = Date_ as DefineComponent<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')
|
||||
})
|
||||
})
|
||||
})
|
||||
93
app/components/malio/date/Date.vue
Normal file
93
app/components/malio/date/Date.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<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>
|
||||
155
app/components/malio/date/DateRange.test.ts
Normal file
155
app/components/malio/date/DateRange.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import DateRange from './DateRange.vue'
|
||||
|
||||
type RangeValue = {start: string; end: string}
|
||||
type DateRangeProps = {
|
||||
modelValue?: RangeValue | null
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
error?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
const DateRangeForTest = DateRange as DefineComponent<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)
|
||||
})
|
||||
})
|
||||
140
app/components/malio/date/DateRange.vue
Normal file
140
app/components/malio/date/DateRange.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<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>
|
||||
120
app/components/malio/date/DateTime.test.ts
Normal file
120
app/components/malio/date/DateTime.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import type {DefineComponent} from 'vue'
|
||||
import DateTime_ from './DateTime.vue'
|
||||
|
||||
type DateTimeProps = {
|
||||
id?: string
|
||||
name?: string
|
||||
label?: string
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
hint?: string
|
||||
error?: string
|
||||
success?: string
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
}
|
||||
|
||||
const DateTimeForTest = DateTime_ as DefineComponent<DateTimeProps>
|
||||
const mountDateTime = (props: DateTimeProps = {}) =>
|
||||
mount(DateTimeForTest, {props, attachTo: document.body})
|
||||
|
||||
describe('MalioDateTime', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('rendu', () => {
|
||||
it('affiche le label et l\'icône calendrier', () => {
|
||||
const wrapper = mountDateTime({label: 'Rendez-vous'})
|
||||
expect(wrapper.get('label').text()).toBe('Rendez-vous')
|
||||
expect(wrapper.find('[data-test="calendar-icon"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('affiche la valeur formatée date + heure dans le champ', () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
const input = wrapper.get('[data-test="date-input"]').element as HTMLInputElement
|
||||
expect(input.value).toBe('20/05/2026 14:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('popover', () => {
|
||||
it('ouvre la grille et l\'input heure au clic', async () => {
|
||||
const wrapper = mountDateTime()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="month-grid"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="time-input"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sélection', () => {
|
||||
it('émet le jour à 00:00 et garde le popover ouvert', async () => {
|
||||
const wrapper = mountDateTime()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T00:00:00'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('applique l\'heure réglée avant le clic du jour', async () => {
|
||||
const wrapper = mountDateTime()
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="time-input"]').setValue('09:15')
|
||||
// pas d'émission tant qu'aucun jour n'est choisi
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await wrapper.get('[data-test="day"][data-iso="2026-05-19"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19T09:15:00'])
|
||||
})
|
||||
|
||||
it('met à jour l\'heure quand une date est déjà choisie', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
await wrapper.get('[data-test="time-input"]').setValue('08:45')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-20T08:45:00'])
|
||||
})
|
||||
|
||||
it('initialise l\'input heure depuis la valeur', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const time = wrapper.get('[data-test="time-input"]').element as HTMLInputElement
|
||||
expect(time.value).toBe('14:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bornes min/max', () => {
|
||||
it('désactive les jours hors bornes (datetime borné sur la date)', async () => {
|
||||
const wrapper = mountDateTime({min: '2026-05-10T00:00:00', max: '2026-05-20T00:00:00'})
|
||||
await wrapper.get('[data-test="date-input"]').trigger('click')
|
||||
const outside = wrapper.get('[data-test="day"][data-iso="2026-05-05"]')
|
||||
expect((outside.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('effacement', () => {
|
||||
it('émet null au clic sur la croix', async () => {
|
||||
const wrapper = mountDateTime({modelValue: '2026-05-20T14:30:00'})
|
||||
await wrapper.get('[data-test="clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
})
|
||||
|
||||
describe('accessibilité', () => {
|
||||
it('positionne aria-invalid et describedby sur erreur', () => {
|
||||
const wrapper = mountDateTime({error: 'Date requise'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBeTruthy()
|
||||
expect(wrapper.text()).toContain('Date requise')
|
||||
})
|
||||
})
|
||||
})
|
||||
134
app/components/malio/date/DateTime.vue
Normal file
134
app/components/malio/date/DateTime.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<CalendarField
|
||||
:id="id"
|
||||
:display-value="displayValue"
|
||||
:sync-to="datePart"
|
||||
: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"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear }">
|
||||
<MonthGrid
|
||||
:month="currentMonth"
|
||||
:year="currentYear"
|
||||
:selected-date="datePart"
|
||||
:min="min?.slice(0, 10)"
|
||||
:max="max?.slice(0, 10)"
|
||||
@select="onSelectDay"
|
||||
/>
|
||||
<!-- Bloc heure intérimaire : input natif, isolé pour remplacement futur par le sélecteur dédié. -->
|
||||
<div class="mt-[26px] flex-col items-center gap-2">
|
||||
<input
|
||||
:id="timeInputId"
|
||||
data-test="time-input"
|
||||
type="time"
|
||||
:value="timeValue"
|
||||
class="w-full border border-m-muted bg-white px-2 py-1 text-base outline-none focus:border-m-primary"
|
||||
@input="onTimeInput"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, useId, watch} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import {composeDateTime, formatIsoDateTimeToDisplay, isValidIsoDateTime, splitDateTime} from './composables/datetimeFormat'
|
||||
|
||||
defineOptions({name: 'MalioDateTime', 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 HH:MM',
|
||||
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 generatedId = useId()
|
||||
const timeInputId = computed(() => `${props.id || `malio-datetime-${generatedId}`}-time`)
|
||||
|
||||
// pendingTime : heure réglée avant qu'un jour ne soit choisi (sinon on ne peut pas émettre).
|
||||
const pendingTime = ref('')
|
||||
|
||||
const parts = computed(() => splitDateTime(props.modelValue ?? null))
|
||||
const datePart = computed(() => parts.value.date)
|
||||
const displayValue = computed(() => formatIsoDateTimeToDisplay(props.modelValue ?? null))
|
||||
const timeValue = computed(() => parts.value.time || pendingTime.value)
|
||||
|
||||
function onSelectDay(iso: string) {
|
||||
const time = parts.value.time || pendingTime.value || '00:00'
|
||||
emit('update:modelValue', composeDateTime(iso, time))
|
||||
}
|
||||
|
||||
function onTimeInput(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
if (!value) return
|
||||
if (datePart.value) {
|
||||
emit('update:modelValue', composeDateTime(datePart.value, value))
|
||||
}
|
||||
else {
|
||||
pendingTime.value = value
|
||||
}
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
pendingTime.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val && !isValidIsoDateTime(val) && import.meta.dev) {
|
||||
console.warn(`[MalioDateTime] modelValue invalide ignoré : "${val}"`)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
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>
|
||||
62
app/components/malio/date/composables/dateFormat.test.ts
Normal file
62
app/components/malio/date/composables/dateFormat.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './dateFormat'
|
||||
|
||||
describe('dateFormat', () => {
|
||||
describe('isValidIso', () => {
|
||||
it('accepts a real ISO date', () => {
|
||||
expect(isValidIso('2026-05-19')).toBe(true)
|
||||
})
|
||||
it('rejects a malformed string', () => {
|
||||
expect(isValidIso('19/05/2026')).toBe(false)
|
||||
expect(isValidIso('2026-5-9')).toBe(false)
|
||||
expect(isValidIso('')).toBe(false)
|
||||
})
|
||||
it('rejects an impossible date', () => {
|
||||
expect(isValidIso('2026-02-30')).toBe(false)
|
||||
expect(isValidIso('2026-13-01')).toBe(false)
|
||||
})
|
||||
it('accepts Feb 29 on a leap year and rejects it otherwise', () => {
|
||||
expect(isValidIso('2024-02-29')).toBe(true)
|
||||
expect(isValidIso('2026-02-29')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatIsoToDisplay', () => {
|
||||
it('formats ISO to DD/MM/YYYY', () => {
|
||||
expect(formatIsoToDisplay('2026-05-19')).toBe('19/05/2026')
|
||||
})
|
||||
it('returns empty string for null or invalid input', () => {
|
||||
expect(formatIsoToDisplay(null)).toBe('')
|
||||
expect(formatIsoToDisplay('nope')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDisplayToIso', () => {
|
||||
it('parses DD/MM/YYYY to ISO', () => {
|
||||
expect(parseDisplayToIso('19/05/2026')).toBe('2026-05-19')
|
||||
})
|
||||
it('returns null for malformed or impossible input', () => {
|
||||
expect(parseDisplayToIso('2026-05-19')).toBeNull()
|
||||
expect(parseDisplayToIso('31/02/2026')).toBeNull()
|
||||
expect(parseDisplayToIso('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDateInRange', () => {
|
||||
it('returns true when no bounds are given', () => {
|
||||
expect(isDateInRange('2026-05-19')).toBe(true)
|
||||
})
|
||||
it('respects the min bound (inclusive)', () => {
|
||||
expect(isDateInRange('2026-05-19', '2026-05-19')).toBe(true)
|
||||
expect(isDateInRange('2026-05-18', '2026-05-19')).toBe(false)
|
||||
})
|
||||
it('respects the max bound (inclusive)', () => {
|
||||
expect(isDateInRange('2026-05-19', undefined, '2026-05-19')).toBe(true)
|
||||
expect(isDateInRange('2026-05-20', undefined, '2026-05-19')).toBe(false)
|
||||
})
|
||||
it('respects both bounds', () => {
|
||||
expect(isDateInRange('2026-05-15', '2026-05-10', '2026-05-20')).toBe(true)
|
||||
expect(isDateInRange('2026-05-25', '2026-05-10', '2026-05-20')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
26
app/components/malio/date/composables/dateFormat.ts
Normal file
26
app/components/malio/date/composables/dateFormat.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function isValidIso(iso: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
const date = new Date(y, m - 1, d)
|
||||
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d
|
||||
}
|
||||
|
||||
export function formatIsoToDisplay(iso: string | null): string {
|
||||
if (!iso || !isValidIso(iso)) return ''
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
export function parseDisplayToIso(display: string): string | null {
|
||||
const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(display.trim())
|
||||
if (!match) return null
|
||||
const [, dd, mm, yyyy] = match
|
||||
const iso = `${yyyy}-${mm}-${dd}`
|
||||
return isValidIso(iso) ? iso : null
|
||||
}
|
||||
|
||||
export function isDateInRange(iso: string, min?: string, max?: string): boolean {
|
||||
if (min && iso < min) return false
|
||||
if (max && iso > max) return false
|
||||
return true
|
||||
}
|
||||
57
app/components/malio/date/composables/dateRange.test.ts
Normal file
57
app/components/malio/date/composables/dateRange.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {dayRangeRole, normalizeRange, resolveRangeBounds} from './dateRange'
|
||||
|
||||
describe('dateRange', () => {
|
||||
describe('normalizeRange', () => {
|
||||
it('keeps an already ordered pair', () => {
|
||||
expect(normalizeRange('2026-05-19', '2026-05-25')).toEqual({start: '2026-05-19', end: '2026-05-25'})
|
||||
})
|
||||
it('swaps a reversed pair', () => {
|
||||
expect(normalizeRange('2026-05-25', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-25'})
|
||||
})
|
||||
it('handles an equal pair', () => {
|
||||
expect(normalizeRange('2026-05-19', '2026-05-19')).toEqual({start: '2026-05-19', end: '2026-05-19'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRangeBounds', () => {
|
||||
it('returns null without a start', () => {
|
||||
expect(resolveRangeBounds(null, null, null)).toBeNull()
|
||||
})
|
||||
it('returns a single-point range when only start is set', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', null, null)).toEqual({lo: '2026-05-19', hi: '2026-05-19'})
|
||||
})
|
||||
it('orders start and committed end', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', '2026-05-25', null)).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
|
||||
})
|
||||
it('uses preview when end is not set', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', null, '2026-05-22')).toEqual({lo: '2026-05-19', hi: '2026-05-22'})
|
||||
})
|
||||
it('inverts when preview is before start', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', null, '2026-05-10')).toEqual({lo: '2026-05-10', hi: '2026-05-19'})
|
||||
})
|
||||
it('prioritises committed end over preview', () => {
|
||||
expect(resolveRangeBounds('2026-05-19', '2026-05-25', '2026-05-30')).toEqual({lo: '2026-05-19', hi: '2026-05-25'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('dayRangeRole', () => {
|
||||
const bounds = {lo: '2026-05-19', hi: '2026-05-25'}
|
||||
it('returns none without bounds', () => {
|
||||
expect(dayRangeRole('2026-05-20', null)).toBe('none')
|
||||
})
|
||||
it('returns single when lo === hi and matches', () => {
|
||||
expect(dayRangeRole('2026-05-19', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('single')
|
||||
expect(dayRangeRole('2026-05-20', {lo: '2026-05-19', hi: '2026-05-19'})).toBe('none')
|
||||
})
|
||||
it('returns start, end and in-range', () => {
|
||||
expect(dayRangeRole('2026-05-19', bounds)).toBe('start')
|
||||
expect(dayRangeRole('2026-05-25', bounds)).toBe('end')
|
||||
expect(dayRangeRole('2026-05-22', bounds)).toBe('in-range')
|
||||
})
|
||||
it('returns none outside the bounds', () => {
|
||||
expect(dayRangeRole('2026-05-10', bounds)).toBe('none')
|
||||
expect(dayRangeRole('2026-05-30', bounds)).toBe('none')
|
||||
})
|
||||
})
|
||||
})
|
||||
31
app/components/malio/date/composables/dateRange.ts
Normal file
31
app/components/malio/date/composables/dateRange.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type DateRangeValue = {start: string; end: string}
|
||||
|
||||
export function normalizeRange(a: string, b: string): DateRangeValue {
|
||||
return a <= b ? {start: a, end: b} : {start: b, end: a}
|
||||
}
|
||||
|
||||
export function resolveRangeBounds(
|
||||
start: string | null,
|
||||
end: string | null,
|
||||
preview: string | null,
|
||||
): {lo: string; hi: string} | null {
|
||||
if (!start) return null
|
||||
const other = end ?? preview
|
||||
if (!other) return {lo: start, hi: start}
|
||||
return start <= other ? {lo: start, hi: other} : {lo: other, hi: start}
|
||||
}
|
||||
|
||||
export type DayRangeRole = 'none' | 'single' | 'start' | 'end' | 'in-range'
|
||||
|
||||
export function dayRangeRole(
|
||||
iso: string,
|
||||
bounds: {lo: string; hi: string} | null,
|
||||
): DayRangeRole {
|
||||
if (!bounds) return 'none'
|
||||
const {lo, hi} = bounds
|
||||
if (lo === hi) return iso === lo ? 'single' : 'none'
|
||||
if (iso === lo) return 'start'
|
||||
if (iso === hi) return 'end'
|
||||
if (iso > lo && iso < hi) return 'in-range'
|
||||
return 'none'
|
||||
}
|
||||
74
app/components/malio/date/composables/dateWeek.test.ts
Normal file
74
app/components/malio/date/composables/dateWeek.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {
|
||||
formatWeekDisplay,
|
||||
isValidIsoWeek,
|
||||
isoWeekToMonday,
|
||||
mondayOf,
|
||||
sundayOf,
|
||||
toIsoWeek,
|
||||
} from './dateWeek'
|
||||
|
||||
describe('dateWeek', () => {
|
||||
describe('mondayOf / sundayOf', () => {
|
||||
it('returns Monday and Sunday of a midweek date', () => {
|
||||
expect(mondayOf('2026-05-20')).toBe('2026-05-18') // mercredi
|
||||
expect(sundayOf('2026-05-20')).toBe('2026-05-24')
|
||||
})
|
||||
it('keeps Monday on a Monday', () => {
|
||||
expect(mondayOf('2026-05-18')).toBe('2026-05-18')
|
||||
})
|
||||
it('returns the preceding Monday for a Sunday', () => {
|
||||
expect(mondayOf('2026-05-24')).toBe('2026-05-18')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toIsoWeek', () => {
|
||||
it('returns the ISO week of a date', () => {
|
||||
expect(toIsoWeek('2026-05-20')).toBe('2026-W21')
|
||||
})
|
||||
it('handles year boundaries', () => {
|
||||
expect(toIsoWeek('2026-01-01')).toBe('2026-W01')
|
||||
expect(toIsoWeek('2025-12-31')).toBe('2026-W01')
|
||||
expect(toIsoWeek('2027-01-01')).toBe('2026-W53')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isoWeekToMonday', () => {
|
||||
it('returns the Monday of a week string', () => {
|
||||
expect(isoWeekToMonday('2026-W21')).toBe('2026-05-18')
|
||||
})
|
||||
it('round-trips with toIsoWeek', () => {
|
||||
for (const w of ['2026-W01', '2026-W21', '2026-W53', '2024-W09']) {
|
||||
const monday = isoWeekToMonday(w)
|
||||
expect(monday).not.toBeNull()
|
||||
expect(toIsoWeek(monday as string)).toBe(w)
|
||||
}
|
||||
})
|
||||
it('returns null for invalid input', () => {
|
||||
expect(isoWeekToMonday('2026-21')).toBeNull()
|
||||
expect(isoWeekToMonday('2026-W00')).toBeNull()
|
||||
expect(isoWeekToMonday('2026-W54')).toBeNull()
|
||||
expect(isoWeekToMonday('2025-W53')).toBeNull() // 2025 n'a que 52 semaines ISO
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidIsoWeek', () => {
|
||||
it('accepts a real ISO week', () => {
|
||||
expect(isValidIsoWeek('2026-W21')).toBe(true)
|
||||
})
|
||||
it('rejects malformed or impossible weeks', () => {
|
||||
expect(isValidIsoWeek('2026-21')).toBe(false)
|
||||
expect(isValidIsoWeek('2026-W00')).toBe(false)
|
||||
expect(isValidIsoWeek('2026-W54')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatWeekDisplay', () => {
|
||||
it('formats a week as a human label', () => {
|
||||
expect(formatWeekDisplay('2026-W21')).toBe('Semaine 21 (18/05 → 24/05/2026)')
|
||||
})
|
||||
it('returns empty string for invalid input', () => {
|
||||
expect(formatWeekDisplay('2026-W54')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
67
app/components/malio/date/composables/dateWeek.ts
Normal file
67
app/components/malio/date/composables/dateWeek.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {formatIsoToDisplay} from './dateFormat'
|
||||
|
||||
const parseUtc = (iso: string): Date => {
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d))
|
||||
}
|
||||
|
||||
const toIso = (d: Date): string => {
|
||||
const y = d.getUTCFullYear()
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getUTCDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
export function mondayOf(iso: string): string {
|
||||
const d = parseUtc(iso)
|
||||
const dayNum = d.getUTCDay() || 7 // dimanche = 7
|
||||
d.setUTCDate(d.getUTCDate() - (dayNum - 1))
|
||||
return toIso(d)
|
||||
}
|
||||
|
||||
export function sundayOf(iso: string): string {
|
||||
const d = parseUtc(mondayOf(iso))
|
||||
d.setUTCDate(d.getUTCDate() + 6)
|
||||
return toIso(d)
|
||||
}
|
||||
|
||||
export function toIsoWeek(iso: string): string {
|
||||
const d = parseUtc(iso)
|
||||
const dayNum = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum) // jeudi de la semaine
|
||||
const isoYear = d.getUTCFullYear()
|
||||
const yearStart = new Date(Date.UTC(isoYear, 0, 1))
|
||||
const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
|
||||
return `${isoYear}-W${String(week).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function isoWeekToMonday(week: string): string | null {
|
||||
const m = /^(\d{4})-W(\d{2})$/.exec(week)
|
||||
if (!m) return null
|
||||
const year = Number(m[1])
|
||||
const w = Number(m[2])
|
||||
if (w < 1 || w > 53) return null
|
||||
// Lundi de la semaine 1 = lundi de la semaine contenant le 4 janvier
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4))
|
||||
const jan4Day = jan4.getUTCDay() || 7
|
||||
const monday = new Date(jan4)
|
||||
monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1) + (w - 1) * 7)
|
||||
const iso = toIso(monday)
|
||||
// Garde-fou : la semaine 53 n'existe pas pour toutes les années
|
||||
if (toIsoWeek(iso) !== week) return null
|
||||
return iso
|
||||
}
|
||||
|
||||
export function isValidIsoWeek(week: string): boolean {
|
||||
return isoWeekToMonday(week) !== null
|
||||
}
|
||||
|
||||
export function formatWeekDisplay(week: string): string {
|
||||
const monday = isoWeekToMonday(week)
|
||||
if (!monday) return ''
|
||||
const sunday = sundayOf(monday)
|
||||
const w = Number(week.slice(6))
|
||||
const startDdMm = formatIsoToDisplay(monday).slice(0, 5) // "18/05"
|
||||
const endFull = formatIsoToDisplay(sunday) // "24/05/2026"
|
||||
return `Semaine ${w} (${startDdMm} → ${endFull})`
|
||||
}
|
||||
61
app/components/malio/date/composables/datetimeFormat.test.ts
Normal file
61
app/components/malio/date/composables/datetimeFormat.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {
|
||||
composeDateTime,
|
||||
formatIsoDateTimeToDisplay,
|
||||
isValidIsoDateTime,
|
||||
splitDateTime,
|
||||
} from './datetimeFormat'
|
||||
|
||||
describe('datetimeFormat', () => {
|
||||
describe('isValidIsoDateTime', () => {
|
||||
it('accepte un datetime ISO complet valide', () => {
|
||||
expect(isValidIsoDateTime('2026-05-20T14:30:00')).toBe(true)
|
||||
expect(isValidIsoDateTime('2026-01-01T00:00:00')).toBe(true)
|
||||
expect(isValidIsoDateTime('2026-12-31T23:59:59')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejette une date seule, des composants invalides ou une chaîne vide', () => {
|
||||
expect(isValidIsoDateTime('2026-05-20')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-13-01T00:00:00')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-05-20T24:00:00')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-05-20T14:60:00')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-05-20T14:30:60')).toBe(false)
|
||||
expect(isValidIsoDateTime('2026-05-20T14:30')).toBe(false)
|
||||
expect(isValidIsoDateTime('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatIsoDateTimeToDisplay', () => {
|
||||
it('formate un datetime valide en JJ/MM/AAAA HH:MM', () => {
|
||||
expect(formatIsoDateTimeToDisplay('2026-05-20T14:30:00')).toBe('20/05/2026 14:30')
|
||||
})
|
||||
|
||||
it('renvoie une chaîne vide pour nul ou invalide', () => {
|
||||
expect(formatIsoDateTimeToDisplay(null)).toBe('')
|
||||
expect(formatIsoDateTimeToDisplay('2026-05-20')).toBe('')
|
||||
expect(formatIsoDateTimeToDisplay('nope')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitDateTime', () => {
|
||||
it('découpe un datetime valide', () => {
|
||||
expect(splitDateTime('2026-05-20T14:30:00')).toEqual({date: '2026-05-20', time: '14:30'})
|
||||
})
|
||||
|
||||
it('renvoie date null et time vide pour nul, date seule ou invalide', () => {
|
||||
expect(splitDateTime(null)).toEqual({date: null, time: ''})
|
||||
expect(splitDateTime('2026-05-20')).toEqual({date: null, time: ''})
|
||||
expect(splitDateTime('nope')).toEqual({date: null, time: ''})
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeDateTime', () => {
|
||||
it('recompose un datetime ISO avec secondes à 00', () => {
|
||||
expect(composeDateTime('2026-05-20', '14:30')).toBe('2026-05-20T14:30:00')
|
||||
})
|
||||
|
||||
it('utilise 00:00 quand l\'heure est vide', () => {
|
||||
expect(composeDateTime('2026-05-20', '')).toBe('2026-05-20T00:00:00')
|
||||
})
|
||||
})
|
||||
})
|
||||
33
app/components/malio/date/composables/datetimeFormat.ts
Normal file
33
app/components/malio/date/composables/datetimeFormat.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {isValidIso} from './dateFormat'
|
||||
|
||||
const DATETIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})$/
|
||||
|
||||
export function isValidIsoDateTime(s: string): boolean {
|
||||
const m = DATETIME_RE.exec(s)
|
||||
if (!m) return false
|
||||
const [, date, hh, mm, ss] = m
|
||||
if (!isValidIso(date)) return false
|
||||
const h = Number(hh)
|
||||
const min = Number(mm)
|
||||
const sec = Number(ss)
|
||||
return h >= 0 && h <= 23 && min >= 0 && min <= 59 && sec >= 0 && sec <= 59
|
||||
}
|
||||
|
||||
export function formatIsoDateTimeToDisplay(s: string | null): string {
|
||||
if (!s || !isValidIsoDateTime(s)) return ''
|
||||
const [date, time] = s.split('T')
|
||||
const [y, mo, d] = date.split('-')
|
||||
const [hh, mm] = time.split(':')
|
||||
return `${d}/${mo}/${y} ${hh}:${mm}`
|
||||
}
|
||||
|
||||
export function splitDateTime(s: string | null): {date: string | null; time: string} {
|
||||
if (!s || !isValidIsoDateTime(s)) return {date: null, time: ''}
|
||||
const [date, time] = s.split('T')
|
||||
return {date, time: time.slice(0, 5)}
|
||||
}
|
||||
|
||||
export function composeDateTime(date: string, time: string): string {
|
||||
const t = time || '00:00'
|
||||
return `${date}T${t}:00`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {defineComponent, h, ref} from 'vue'
|
||||
import {mount} from '@vue/test-utils'
|
||||
import {useCalendarPopover} from './useCalendarPopover'
|
||||
|
||||
const mountHost = () => {
|
||||
const api: ReturnType<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)
|
||||
})
|
||||
})
|
||||
28
app/components/malio/date/composables/useCalendarPopover.ts
Normal file
28
app/components/malio/date/composables/useCalendarPopover.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {ref} from 'vue'
|
||||
import {useCalendarView} from './useCalendarView'
|
||||
|
||||
describe('useCalendarView', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('initialises to the current month and year', () => {
|
||||
const {currentMonth, currentYear} = useCalendarView(ref('days'))
|
||||
expect(currentMonth.value).toBe(4)
|
||||
expect(currentYear.value).toBe(2026)
|
||||
})
|
||||
|
||||
it('goToNext advances the month in days view', () => {
|
||||
const {currentMonth, goToNext} = useCalendarView(ref('days'))
|
||||
goToNext()
|
||||
expect(currentMonth.value).toBe(5)
|
||||
})
|
||||
|
||||
it('rolls December to January and bumps the year', () => {
|
||||
const {currentMonth, currentYear, goToNext} = useCalendarView(ref('days'))
|
||||
currentMonth.value = 11
|
||||
goToNext()
|
||||
expect(currentMonth.value).toBe(0)
|
||||
expect(currentYear.value).toBe(2027)
|
||||
})
|
||||
|
||||
it('rolls January to December backwards', () => {
|
||||
const {currentMonth, currentYear, goToPrev} = useCalendarView(ref('days'))
|
||||
currentMonth.value = 0
|
||||
goToPrev()
|
||||
expect(currentMonth.value).toBe(11)
|
||||
expect(currentYear.value).toBe(2025)
|
||||
})
|
||||
|
||||
it('navigates the year in months view', () => {
|
||||
const {currentYear, goToNext, goToPrev} = useCalendarView(ref('months'))
|
||||
goToNext()
|
||||
expect(currentYear.value).toBe(2027)
|
||||
goToPrev()
|
||||
expect(currentYear.value).toBe(2026)
|
||||
})
|
||||
|
||||
it('selectMonth sets the current month', () => {
|
||||
const {currentMonth, selectMonth} = useCalendarView(ref('days'))
|
||||
selectMonth(0)
|
||||
expect(currentMonth.value).toBe(0)
|
||||
})
|
||||
|
||||
it('syncToIso sets month/year from a valid ISO', () => {
|
||||
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
|
||||
syncToIso('2025-12-25')
|
||||
expect(currentMonth.value).toBe(11)
|
||||
expect(currentYear.value).toBe(2025)
|
||||
})
|
||||
|
||||
it('syncToIso falls back to today for null/invalid', () => {
|
||||
const {currentMonth, currentYear, syncToIso} = useCalendarView(ref('days'))
|
||||
syncToIso('2025-12-25')
|
||||
syncToIso(null)
|
||||
expect(currentMonth.value).toBe(4)
|
||||
expect(currentYear.value).toBe(2026)
|
||||
})
|
||||
})
|
||||
51
app/components/malio/date/composables/useCalendarView.ts
Normal file
51
app/components/malio/date/composables/useCalendarView.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {ref, type Ref} from 'vue'
|
||||
import {isValidIso} from './dateFormat'
|
||||
|
||||
export function useCalendarView(viewMode: Ref<'days' | 'months'>) {
|
||||
const today = new Date()
|
||||
const currentMonth = ref(today.getMonth())
|
||||
const currentYear = ref(today.getFullYear())
|
||||
|
||||
const goToPrev = () => {
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value -= 1
|
||||
return
|
||||
}
|
||||
if (currentMonth.value === 0) {
|
||||
currentMonth.value = 11
|
||||
currentYear.value -= 1
|
||||
} else {
|
||||
currentMonth.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
const goToNext = () => {
|
||||
if (viewMode.value === 'months') {
|
||||
currentYear.value += 1
|
||||
return
|
||||
}
|
||||
if (currentMonth.value === 11) {
|
||||
currentMonth.value = 0
|
||||
currentYear.value += 1
|
||||
} else {
|
||||
currentMonth.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
const selectMonth = (m: number) => {
|
||||
currentMonth.value = m
|
||||
}
|
||||
|
||||
const syncToIso = (iso: string | null) => {
|
||||
if (iso && isValidIso(iso)) {
|
||||
currentMonth.value = Number(iso.slice(5, 7)) - 1
|
||||
currentYear.value = Number(iso.slice(0, 4))
|
||||
} else {
|
||||
const now = new Date()
|
||||
currentMonth.value = now.getMonth()
|
||||
currentYear.value = now.getFullYear()
|
||||
}
|
||||
}
|
||||
|
||||
return {currentMonth, currentYear, goToPrev, goToNext, selectMonth, syncToIso}
|
||||
}
|
||||
69
app/components/malio/date/composables/useMonthMatrix.test.ts
Normal file
69
app/components/malio/date/composables/useMonthMatrix.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
||||
import {ref} from 'vue'
|
||||
import {useMonthMatrix} from './useMonthMatrix'
|
||||
|
||||
describe('useMonthMatrix', () => {
|
||||
it('always produces 6 weeks of 7 days', () => {
|
||||
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai 2026
|
||||
expect(weeks.value).toHaveLength(6)
|
||||
weeks.value.forEach(week => expect(week.days).toHaveLength(7))
|
||||
})
|
||||
|
||||
it('starts every week on a Monday', () => {
|
||||
const {weeks} = useMonthMatrix(ref(4), ref(2026))
|
||||
weeks.value.forEach(week => {
|
||||
const first = new Date(`${week.days[0].isoDate}T00:00:00`)
|
||||
expect(first.getDay()).toBe(1) // 1 = lundi
|
||||
})
|
||||
})
|
||||
|
||||
it('flags exactly the days of the current month', () => {
|
||||
const {weeks} = useMonthMatrix(ref(4), ref(2026)) // mai = 31 jours
|
||||
const currentMonthDays = weeks.value
|
||||
.flatMap(w => w.days)
|
||||
.filter(d => d.isCurrentMonth)
|
||||
expect(currentMonthDays).toHaveLength(31)
|
||||
expect(currentMonthDays.every(d => d.isoDate.startsWith('2026-05'))).toBe(true)
|
||||
})
|
||||
|
||||
it('handles leap year February (29 days)', () => {
|
||||
const {weeks} = useMonthMatrix(ref(1), ref(2024)) // février 2024
|
||||
const days = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth)
|
||||
expect(days).toHaveLength(29)
|
||||
})
|
||||
|
||||
it('assigns ISO week 1 to the week containing Jan 4th', () => {
|
||||
const {weeks} = useMonthMatrix(ref(0), ref(2026)) // janvier 2026
|
||||
const weekWithJan4 = weeks.value.find(w =>
|
||||
w.days.some(d => d.isoDate === '2026-01-04'),
|
||||
)
|
||||
expect(weekWithJan4?.weekNumber).toBe(1)
|
||||
})
|
||||
|
||||
it('reacts to month/year changes', () => {
|
||||
const month = ref(4)
|
||||
const year = ref(2026)
|
||||
const {weeks} = useMonthMatrix(month, year)
|
||||
const mayCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
|
||||
month.value = 1 // février
|
||||
year.value = 2024
|
||||
const febCount = weeks.value.flatMap(w => w.days).filter(d => d.isCurrentMonth).length
|
||||
expect(mayCount).toBe(31)
|
||||
expect(febCount).toBe(29)
|
||||
})
|
||||
|
||||
describe('isToday', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 4, 19)) // 19 mai 2026
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('flags only today', () => {
|
||||
const {weeks} = useMonthMatrix(ref(4), ref(2026))
|
||||
const todays = weeks.value.flatMap(w => w.days).filter(d => d.isToday)
|
||||
expect(todays).toHaveLength(1)
|
||||
expect(todays[0].isoDate).toBe('2026-05-19')
|
||||
})
|
||||
})
|
||||
})
|
||||
60
app/components/malio/date/composables/useMonthMatrix.ts
Normal file
60
app/components/malio/date/composables/useMonthMatrix.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {computed, type ComputedRef, type Ref} from 'vue'
|
||||
|
||||
export type DayCell = {
|
||||
isoDate: string
|
||||
day: number
|
||||
isCurrentMonth: boolean
|
||||
isToday: boolean
|
||||
}
|
||||
export type WeekRow = {
|
||||
weekNumber: number
|
||||
days: DayCell[]
|
||||
}
|
||||
|
||||
const toIso = (d: Date): string => {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
const isoWeek = (d: Date): number => {
|
||||
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
|
||||
const dayNum = target.getUTCDay() || 7 // dimanche = 7
|
||||
target.setUTCDate(target.getUTCDate() + 4 - dayNum) // jeudi de la semaine
|
||||
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1))
|
||||
return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
|
||||
}
|
||||
|
||||
export function useMonthMatrix(
|
||||
month: Ref<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}
|
||||
}
|
||||
239
app/components/malio/date/internal/CalendarField.vue
Normal file
239
app/components/malio/date/internal/CalendarField.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<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>
|
||||
70
app/components/malio/date/internal/CalendarHeader.vue
Normal file
70
app/components/malio/date/internal/CalendarHeader.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<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>
|
||||
178
app/components/malio/date/internal/MonthGrid.vue
Normal file
178
app/components/malio/date/internal/MonthGrid.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<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>
|
||||
36
app/components/malio/date/internal/MonthPicker.vue
Normal file
36
app/components/malio/date/internal/MonthPicker.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user