Compare commits
10 Commits
20709fafd3
...
a2ba08e347
| Author | SHA1 | Date | |
|---|---|---|---|
| a2ba08e347 | |||
| 70b18a6281 | |||
| fc3dcadff6 | |||
| 549e841fb2 | |||
| 463e46dcd0 | |||
| 72240b1977 | |||
| db6a263e16 | |||
| c3d533fad2 | |||
| 429c1d5fd8 | |||
| 9160824abd |
68
.playground/pages/composant/date/datetime.vue
Normal file
68
.playground/pages/composant/date/datetime.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">MalioDateTime</h1>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-10">
|
||||
<div class="w-[480px] space-y-3">
|
||||
<h2 class="font-semibold">Large (480px)</h2>
|
||||
<MalioDateTime
|
||||
v-model="value"
|
||||
label="Date et heure du rendez-vous"
|
||||
hint="Choisis un jour puis une heure"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-m-primary px-3 py-1.5 text-white"
|
||||
@click="value = '2026-12-25T09:30:00'"
|
||||
>
|
||||
Forcer le 25/12/2026 09:30
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-3 py-1.5"
|
||||
@click="value = null"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">ERP (396px)</h2>
|
||||
<MalioDateTime
|
||||
v-model="erpValue"
|
||||
label="Date et heure du rendez-vous"
|
||||
hint="Largeur cible ERP"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO naïf) : <code>{{ erpValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDateTime
|
||||
v-model="bounded"
|
||||
label="Créneau borné"
|
||||
:min="todayIso"
|
||||
:max="maxIso"
|
||||
hint="Entre aujourd'hui et +30 jours"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||
const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||
|
||||
const value = ref<string | null>(null)
|
||||
const erpValue = ref<string | null>(null)
|
||||
const bounded = ref<string | null>('2026-05-20T14:30:00')
|
||||
</script>
|
||||
@@ -32,6 +32,7 @@ export const navSections: SidebarSection[] = [
|
||||
{label: 'Date', to: '/composant/date/date'},
|
||||
{label: 'Plage de dates', to: '/composant/date/dateRange'},
|
||||
{label: 'Semaine', to: '/composant/date/dateWeek'},
|
||||
{label: 'Date & heure', to: '/composant/date/datetime'},
|
||||
{label: 'Heure', to: '/composant/time/time'},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
* [#MUI-32] Création d'un composant saisie assistée (autocomplete)
|
||||
* [#MUI-34] Revoir le système de playground
|
||||
* [#MUI-33] Développer le composant Datepicker
|
||||
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
|
||||
|
||||
### Changed
|
||||
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
|
||||
|
||||
137
COMPONENTS.md
137
COMPONENTS.md
@@ -442,6 +442,106 @@ Bouton radio (à utiliser en groupe avec le même `name`).
|
||||
|
||||
---
|
||||
|
||||
## MalioDate
|
||||
|
||||
Sélecteur de date unique avec popover (grille de calendrier + vue mois/année).
|
||||
|
||||
La valeur est une chaîne ISO `"YYYY-MM-DD"`. Cliquer un jour émet la date et ferme le popover.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
|
||||
| `id` | `string` | `''` | Id du champ |
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Requis |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` (jours antérieurs désactivés) |
|
||||
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` (jours postérieurs désactivés) |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: string | null)`
|
||||
|
||||
```vue
|
||||
<MalioDate v-model="date" label="Date de naissance" />
|
||||
<!-- date === "2026-05-20" -->
|
||||
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MalioDateRange
|
||||
|
||||
Sélecteur de **plage de dates** (date de début → date de fin) dans un seul champ. Cliquer un premier jour démarre la plage, le second la termine ; un survol prévisualise la plage.
|
||||
|
||||
La valeur est un objet `{ start: string; end: string }` (dates ISO `"YYYY-MM-DD"`), ou `null`.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `{ start: string; end: string } \| null` | `undefined` | Plage de dates ISO (v-model) |
|
||||
| `id` | `string` | `''` | Id du champ |
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Requis |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
||||
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: { start: string; end: string } | null)`
|
||||
|
||||
```vue
|
||||
<MalioDateRange v-model="periode" label="Période de séjour" />
|
||||
<!-- periode === { start: "2026-05-20", end: "2026-05-27" } -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MalioDateWeek
|
||||
|
||||
Sélecteur de **semaine ISO** : cliquer un jour (ou un numéro de semaine) sélectionne la semaine entière.
|
||||
|
||||
La valeur est une chaîne au format **semaine ISO native** `"YYYY-Www"` (ex. `"2026-W21"`), ou `null`. Le champ affiche `Semaine W (JJ/MM → JJ/MM/AAAA)`.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string \| null` | `undefined` | Semaine ISO `"YYYY-Www"` (v-model) |
|
||||
| `id` | `string` | `''` | Id du champ |
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Requis |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `min` | `string` | `undefined` | Date min `"YYYY-MM-DD"` |
|
||||
| `max` | `string` | `undefined` | Date max `"YYYY-MM-DD"` |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: string | null)`
|
||||
|
||||
```vue
|
||||
<MalioDateWeek v-model="semaine" label="Semaine de livraison" />
|
||||
<!-- semaine === "2026-W21" -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MalioTime
|
||||
|
||||
Sélecteur d'heure.
|
||||
@@ -463,6 +563,43 @@ Sélecteur d'heure.
|
||||
|
||||
---
|
||||
|
||||
## MalioDateTime
|
||||
|
||||
Champ unique combinant **date et heure** dans un popover (grille de calendrier + sélecteur d'heure sous la grille).
|
||||
|
||||
> ⚠️ **Version intérimaire** : le sélecteur d'heure est un `<input type="time">` natif, en attendant la maquette d'un sélecteur d'heure dédié. Le bloc heure est isolé pour être remplacé sans impact sur le reste.
|
||||
|
||||
La valeur est une chaîne **ISO naïve sans fuseau** au format `"YYYY-MM-DDTHH:MM:00"` (heure murale locale). Symfony (`DateTimeNormalizer`) parse ce format et applique son fuseau configuré côté back — pas de gestion de fuseau côté front.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string \| null` | `undefined` | Date + heure ISO naïve `"YYYY-MM-DDTHH:MM:00"` (v-model) |
|
||||
| `id` | `string` | `''` | Id du champ |
|
||||
| `name` | `string` | `''` | Attribut name |
|
||||
| `label` | `string` | `''` | Label flottant |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | Placeholder |
|
||||
| `required` | `boolean` | `false` | Requis |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Lecture seule |
|
||||
| `hint` | `string` | `''` | Texte d'aide |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `success` | `string` | `''` | Message de succès |
|
||||
| `min` | `string` | `undefined` | Borne min (datetime ou date ; borne la grille sur la partie date) |
|
||||
| `max` | `string` | `undefined` | Borne max (idem) |
|
||||
| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: string | null)`
|
||||
|
||||
Flux : cliquer un jour fixe la date (heure par défaut `00:00`), régler l'heure met à jour l'heure ; le popover se ferme au clic extérieur. La valeur est émise en direct à chaque interaction.
|
||||
|
||||
```vue
|
||||
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
|
||||
<!-- rdv === "2026-05-20T14:30:00" -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MalioButton
|
||||
|
||||
Bouton d'action avec 4 variantes visuelles et icône optionnelle.
|
||||
|
||||
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>
|
||||
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`
|
||||
}
|
||||
76
app/story/date/dateTime.story.vue
Normal file
76
app/story/date/dateTime.story.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Story title="Date/DateTime">
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioDateTime
|
||||
v-model="simpleValue"
|
||||
label="Date et heure"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||
<MalioDateTime
|
||||
v-model="initialValue"
|
||||
label="Rendez-vous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
|
||||
<MalioDateTime
|
||||
v-model="boundedValue"
|
||||
label="Créneau"
|
||||
:min="todayIso"
|
||||
:max="maxIso"
|
||||
hint="Entre aujourd'hui et +30 jours"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioDateTime
|
||||
v-model="errorValue"
|
||||
label="Date limite"
|
||||
error="Date et heure requises"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioDateTime
|
||||
v-model="initialValue"
|
||||
label="Désactivé"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
|
||||
<MalioDateTime
|
||||
v-model="initialValue"
|
||||
label="Lecture seule"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioDateTime from '../../components/malio/date/DateTime.vue'
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||
|
||||
const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||
|
||||
const simpleValue = ref<string | null>(null)
|
||||
const initialValue = ref<string | null>('2026-05-20T14:30:00')
|
||||
const boundedValue = ref<string | null>(null)
|
||||
const errorValue = ref<string | null>(null)
|
||||
</script>
|
||||
712
docs/superpowers/plans/2026-05-22-datetime.md
Normal file
712
docs/superpowers/plans/2026-05-22-datetime.md
Normal file
@@ -0,0 +1,712 @@
|
||||
# MalioDateTime Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ajouter un composant `MalioDateTime` (date + heure dans un seul champ) à la famille temporelle de `@malio/layer-ui`, en version intérimaire avec `<input type="time">` natif.
|
||||
|
||||
**Architecture:** Fine enveloppe autour du shell `internal/CalendarField.vue` (comme `MalioDate`). Le slot popover contient `MonthGrid` (jour) + un `<input type="time">` (heure) sous la grille. La valeur émise est l'ISO naïf `"YYYY-MM-DDTHH:MM:00"`. Logique de découpe/recomposition dans un nouveau composable `datetimeFormat.ts`.
|
||||
|
||||
**Tech Stack:** Vue 3 `<script setup lang="ts">`, TypeScript strict, Tailwind (`m-*`), `tailwind-merge`, Vitest + @vue/test-utils (jsdom).
|
||||
|
||||
**Conventions (rappel) :** Conventional Commits **avec espace avant `:`**, type minuscule, suffixe ticket `(#MUI-33)`, pas de préfixe `[#...]`. Terminer par `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`. Le hook pre-commit est flaky (timeouts WSL2) → après vérification ciblée `npx vitest run <chemin>`, committer avec `--no-verify`.
|
||||
|
||||
**Réfs spec :** `docs/superpowers/specs/2026-05-22-datetime-design.md`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : Composable `datetimeFormat.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/malio/date/composables/datetimeFormat.ts`
|
||||
- Test: `app/components/malio/date/composables/datetimeFormat.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||
|
||||
Créer `app/components/malio/date/composables/datetimeFormat.test.ts` :
|
||||
|
||||
```ts
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/composables/datetimeFormat.test.ts`
|
||||
Expected: FAIL (module `./datetimeFormat` introuvable).
|
||||
|
||||
- [ ] **Step 3 : Écrire l'implémentation**
|
||||
|
||||
Créer `app/components/malio/date/composables/datetimeFormat.ts` :
|
||||
|
||||
```ts
|
||||
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`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/composables/datetimeFormat.test.ts`
|
||||
Expected: PASS (tous verts).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/composables/datetimeFormat.ts app/components/malio/date/composables/datetimeFormat.test.ts
|
||||
git commit --no-verify -m "feat : composable datetimeFormat pour MalioDateTime (#MUI-33)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Composant `DateTime.vue`
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/malio/date/DateTime.vue`
|
||||
- Test: `app/components/malio/date/DateTime.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests qui échouent**
|
||||
|
||||
Créer `app/components/malio/date/DateTime.test.ts` :
|
||||
|
||||
```ts
|
||||
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')
|
||||
const time = wrapper.get('[data-test="time-input"]')
|
||||
;(time.element as HTMLInputElement).value = '09:15'
|
||||
await time.trigger('input')
|
||||
// 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')
|
||||
const time = wrapper.get('[data-test="time-input"]')
|
||||
;(time.element as HTMLInputElement).value = '08:45'
|
||||
await time.trigger('input')
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier qu'ils échouent**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/DateTime.test.ts`
|
||||
Expected: FAIL (`DateTime.vue` introuvable).
|
||||
|
||||
- [ ] **Step 3 : Écrire l'implémentation**
|
||||
|
||||
Créer `app/components/malio/date/DateTime.vue` :
|
||||
|
||||
```vue
|
||||
<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-[10px] flex items-center gap-2 border-t border-m-muted/30 pt-[10px]">
|
||||
<label
|
||||
:for="timeInputId"
|
||||
class="text-sm font-medium text-m-muted"
|
||||
>
|
||||
Heure
|
||||
</label>
|
||||
<input
|
||||
:id="timeInputId"
|
||||
data-test="time-input"
|
||||
type="time"
|
||||
:value="timeValue"
|
||||
class="rounded-md 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>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier qu'ils passent**
|
||||
|
||||
Run: `npx vitest run app/components/malio/date/DateTime.test.ts`
|
||||
Expected: PASS (tous verts).
|
||||
|
||||
Note : si `@input` ne déclenche pas `value` correctement en jsdom, utiliser `await time.setValue('09:15')` à la place du couple `.value =` + `.trigger('input')` dans les tests.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/DateTime.vue app/components/malio/date/DateTime.test.ts
|
||||
git commit --no-verify -m "feat : composant MalioDateTime (date + heure) (#MUI-33)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Page playground + entrée nav
|
||||
|
||||
**Files:**
|
||||
- Create: `.playground/pages/composant/date/datetime.vue`
|
||||
- Modify: `.playground/playground.nav.ts` (section `DATES & HEURES`)
|
||||
|
||||
- [ ] **Step 1 : Créer la page playground**
|
||||
|
||||
Créer `.playground/pages/composant/date/datetime.vue` :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="space-y-6 p-4">
|
||||
<h1 class="text-2xl font-bold">MalioDateTime</h1>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-10">
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">Simple</h2>
|
||||
<MalioDateTime
|
||||
v-model="value"
|
||||
label="Date et heure du rendez-vous"
|
||||
hint="Choisis un jour puis une heure"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO naïf) : <code>{{ value ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-3 py-1.5"
|
||||
@click="value = null"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-[396px] space-y-3">
|
||||
<h2 class="font-semibold">Valeur initiale + bornes</h2>
|
||||
<MalioDateTime
|
||||
v-model="bounded"
|
||||
label="Créneau"
|
||||
:min="todayIso"
|
||||
:max="maxIso"
|
||||
hint="Entre aujourd'hui et +30 jours"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO naïf) : <code>{{ bounded ?? 'null' }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||
const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||
|
||||
const value = ref<string | null>(null)
|
||||
const bounded = ref<string | null>('2026-05-20T14:30:00')
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter l'entrée nav**
|
||||
|
||||
Dans `.playground/playground.nav.ts`, section `DATES & HEURES`, ajouter l'item après `Semaine` :
|
||||
|
||||
```ts
|
||||
{label: 'Date & heure', to: '/composant/date/datetime'},
|
||||
```
|
||||
|
||||
Le bloc devient :
|
||||
|
||||
```ts
|
||||
items: [
|
||||
{label: 'Date', to: '/composant/date/date'},
|
||||
{label: 'Plage de dates', to: '/composant/date/dateRange'},
|
||||
{label: 'Semaine', to: '/composant/date/dateWeek'},
|
||||
{label: 'Date & heure', to: '/composant/date/datetime'},
|
||||
{label: 'Heure', to: '/composant/time/time'},
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Vérifier le lint**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: PASS (pas d'erreur sur les fichiers playground).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add .playground/pages/composant/date/datetime.vue .playground/playground.nav.ts
|
||||
git commit --no-verify -m "feat : page playground MalioDateTime (#MUI-33)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : Story Histoire
|
||||
|
||||
**Files:**
|
||||
- Create: `app/story/date/dateTime.story.vue`
|
||||
|
||||
- [ ] **Step 1 : Créer la story**
|
||||
|
||||
Créer `app/story/date/dateTime.story.vue` (nom de fichier camelCase pour éviter `vue/multi-word-component-names`) :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Story title="Date/DateTime">
|
||||
<div class="grid grid-cols-1 items-start gap-6 md:grid-cols-2">
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Simple</h2>
|
||||
<MalioDateTime
|
||||
v-model="simpleValue"
|
||||
label="Date et heure"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Valeur initiale</h2>
|
||||
<MalioDateTime
|
||||
v-model="initialValue"
|
||||
label="Rendez-vous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec min/max</h2>
|
||||
<MalioDateTime
|
||||
v-model="boundedValue"
|
||||
label="Créneau"
|
||||
:min="todayIso"
|
||||
:max="maxIso"
|
||||
hint="Entre aujourd'hui et +30 jours"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Erreur</h2>
|
||||
<MalioDateTime
|
||||
v-model="errorValue"
|
||||
label="Date limite"
|
||||
error="Date et heure requises"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioDateTime
|
||||
v-model="initialValue"
|
||||
label="Désactivé"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Lecture seule</h2>
|
||||
<MalioDateTime
|
||||
v-model="initialValue"
|
||||
label="Lecture seule"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import MalioDateTime from '../../components/malio/date/DateTime.vue'
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const toIso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00:00`
|
||||
|
||||
const now = new Date()
|
||||
const todayIso = toIso(now)
|
||||
const maxIso = toIso(new Date(now.getTime() + 30 * 86400000))
|
||||
|
||||
const simpleValue = ref<string | null>(null)
|
||||
const initialValue = ref<string | null>('2026-05-20T14:30:00')
|
||||
const boundedValue = ref<string | null>(null)
|
||||
const errorValue = ref<string | null>(null)
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier le lint**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/story/date/dateTime.story.vue
|
||||
git commit --no-verify -m "docs : story Histoire pour MalioDateTime (#MUI-33)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5 : Documentation (COMPONENTS.md + CHANGELOG.md)
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Ajouter la section dans COMPONENTS.md**
|
||||
|
||||
Ouvrir `COMPONENTS.md`, repérer la section `MalioDate` (ou la famille date) et ajouter une section `MalioDateTime` calquée dessus, documentant :
|
||||
- Description : champ unique date + heure, popover (grille + sélecteur d'heure), version intérimaire avec `<input type="time">` natif.
|
||||
- `modelValue` : `string | null`, format `"YYYY-MM-DDTHH:MM:00"` (ISO naïf sans fuseau ; Symfony applique son fuseau).
|
||||
- Table des props identique à `MalioDate` (ajouter la note sur `min`/`max` bornant la date).
|
||||
- Émission `update:modelValue`.
|
||||
- Exemple d'usage :
|
||||
|
||||
```vue
|
||||
<MalioDateTime v-model="rdv" label="Date et heure du rendez-vous" />
|
||||
<!-- rdv === "2026-05-20T14:30:00" -->
|
||||
```
|
||||
|
||||
- Note : le sélecteur d'heure est intérimaire et sera remplacé par un composant dédié (maquette à venir).
|
||||
|
||||
Respecter le style et la structure exacts des sections existantes (titres, tableaux markdown).
|
||||
|
||||
- [ ] **Step 2 : Ajouter l'entrée CHANGELOG.md**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `## [0.0.0]` → `### Added`, ajouter après la ligne `[#MUI-33] Développer le composant Datepicker` :
|
||||
|
||||
```
|
||||
* [#MUI-33] Création du composant DateTime (date + heure, sélecteur d'heure natif intérimaire)
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit --no-verify -m "docs : documente MalioDateTime (COMPONENTS + CHANGELOG) (#MUI-33)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vérification finale
|
||||
|
||||
- [ ] `npx vitest run app/components/malio/date/` → toute la famille date verte.
|
||||
- [ ] `npm run lint` → pas d'erreur.
|
||||
- [ ] Revue finale du composant (cohérence avec la famille date, isolation du bloc heure pour remplacement futur).
|
||||
146
docs/superpowers/specs/2026-05-22-datetime-design.md
Normal file
146
docs/superpowers/specs/2026-05-22-datetime-design.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# MalioDateTime — Design (version intérimaire)
|
||||
|
||||
**Date :** 2026-05-22
|
||||
**Ticket :** #MUI-33 (famille Datepicker)
|
||||
**Statut :** validé, prêt pour le plan d'implémentation
|
||||
|
||||
## Objectif
|
||||
|
||||
Ajouter un composant `MalioDateTime` à la famille temporelle de `@malio/layer-ui`, permettant de saisir **une date ET une heure** dans un seul champ avec popover.
|
||||
|
||||
Cette version est **intérimaire** : le sélecteur d'heure utilise un `<input type="time">` natif, le temps que la maquette du sélecteur d'heure dédié soit fournie. Le bloc heure est volontairement isolé pour pouvoir être remplacé sans toucher au reste.
|
||||
|
||||
## Valeur du modèle
|
||||
|
||||
`modelValue: string | null` au format **ISO naïf sans fuseau** : `"YYYY-MM-DDTHH:MM:00"` (ex. `"2026-05-20T14:30:00"`).
|
||||
|
||||
- Heure murale locale : un picker n'a pas de notion de fuseau.
|
||||
- Symfony (`DateTimeNormalizer`, RFC 3339) parse ce format et applique son fuseau configuré → zéro bug TZ/DST côté front.
|
||||
- Les secondes sont toujours `00` (le natif `type="time"` par défaut n'expose pas les secondes).
|
||||
- Cohérent avec `MalioDate` (`YYYY-MM-DD`) et `MalioTime` (`HH:MM`).
|
||||
|
||||
## Architecture
|
||||
|
||||
Fine enveloppe autour du shell partagé `internal/CalendarField.vue`, comme `MalioDate` / `MalioDateRange` / `MalioDateWeek`.
|
||||
|
||||
```
|
||||
MalioDateTime (Date/DateTime.vue)
|
||||
└─ CalendarField (champ + popover + header + navigation mois)
|
||||
slot #default={ currentMonth, currentYear, close }
|
||||
├─ MonthGrid (sélection du jour)
|
||||
└─ <input type="time"> (sélection de l'heure, sous la grille)
|
||||
```
|
||||
|
||||
Le `close` du slot n'est **pas** appelé sur sélection (contrairement à `MalioDate`) : on a besoin de date ET heure, la fermeture se fait au clic extérieur (déjà gérée par `CalendarField` via `useCalendarPopover`).
|
||||
|
||||
## Props
|
||||
|
||||
Identiques à `MalioDate` :
|
||||
|
||||
| Prop | Type | Défaut | Note |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | `''` | |
|
||||
| `name` | `string` | `''` | |
|
||||
| `label` | `string` | `''` | |
|
||||
| `modelValue` | `string \| null` | `undefined` | `"YYYY-MM-DDTHH:MM:00"` |
|
||||
| `placeholder` | `string` | `'JJ/MM/AAAA HH:MM'` | |
|
||||
| `required` | `boolean` | `false` | |
|
||||
| `disabled` | `boolean` | `false` | |
|
||||
| `readonly` | `boolean` | `false` | |
|
||||
| `hint` | `string` | `''` | |
|
||||
| `error` | `string` | `''` | |
|
||||
| `success` | `string` | `''` | |
|
||||
| `min` | `string` | `undefined` | datetime ou date ; on borne la grille avec la partie date |
|
||||
| `max` | `string` | `undefined` | idem |
|
||||
| `clearable` | `boolean` | `true` | |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | |
|
||||
|
||||
**Émissions :** `update:modelValue: string | null`.
|
||||
|
||||
## Affichage du champ
|
||||
|
||||
`displayValue` = `formatIsoDateTimeToDisplay(modelValue)` → `"JJ/MM/AAAA HH:MM"` (ex. `20/05/2026 14:30`). Chaîne vide si `modelValue` nul ou invalide.
|
||||
|
||||
## Flux de sélection
|
||||
|
||||
État : `modelValue` est la source de vérité. Une ref locale `pendingTime: string` (`'HH:MM'` ou `''`) sert de pont quand l'heure est réglée avant qu'un jour soit choisi.
|
||||
|
||||
- **Partie date courante** = `splitDateTime(modelValue).date` (ou `null`).
|
||||
- **Valeur de l'`<input type="time">`** = `splitDateTime(modelValue).time` si présent, sinon `pendingTime`.
|
||||
|
||||
Interactions :
|
||||
|
||||
1. **Clic sur un jour `iso`** :
|
||||
- `heureEffective` = partie heure de `modelValue`, sinon `pendingTime`, sinon `'00:00'`.
|
||||
- émet `composeDateTime(iso, heureEffective)` → `"iso T heure:00"`.
|
||||
- le popover **reste ouvert**.
|
||||
2. **Changement de l'`<input type="time">` (`hhmm`)** :
|
||||
- si une partie date existe → émet `composeDateTime(datePart, hhmm)`.
|
||||
- sinon → stocke `hhmm` dans `pendingTime` (pas d'émission tant qu'aucun jour n'est choisi).
|
||||
- si `hhmm` est vidé (`''`) et qu'une date existe → on garde la date avec `00:00` ? **Non** : on n'émet rien sur vidage, on conserve la dernière valeur émise (le natif renvoie rarement `''` une fois rempli ; cas négligé pour l'intérim).
|
||||
3. **Effacer (croix)** : émet `null`, `pendingTime = ''`.
|
||||
|
||||
Bornes `min`/`max` : passées à `MonthGrid` après slice de la partie date (`min?.slice(0, 10)`). Pas de borne sur l'heure dans cette version.
|
||||
|
||||
## Composable `composables/datetimeFormat.ts`
|
||||
|
||||
Réutilise `isValidIso` de `dateFormat.ts` pour valider la partie date.
|
||||
|
||||
```ts
|
||||
// "YYYY-MM-DDTHH:MM:00" complet et valide ?
|
||||
export function isValidIsoDateTime(s: string): boolean
|
||||
|
||||
// "YYYY-MM-DDTHH:MM:00" -> "JJ/MM/AAAA HH:MM" (chaîne vide si invalide/nul)
|
||||
export function formatIsoDateTimeToDisplay(s: string | null): string
|
||||
|
||||
// "YYYY-MM-DDTHH:MM:00" -> { date: "YYYY-MM-DD" | null, time: "HH:MM" | '' }
|
||||
export function splitDateTime(s: string | null): { date: string | null; time: string }
|
||||
|
||||
// (date "YYYY-MM-DD", time "HH:MM") -> "YYYY-MM-DDTHH:MM:00"
|
||||
export function composeDateTime(date: string, time: string): string
|
||||
```
|
||||
|
||||
Règles :
|
||||
- `isValidIsoDateTime` : regex `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$`, partie date valide via `isValidIso`, heure `00–23`, minutes `00–59`, secondes `00–59`.
|
||||
- `splitDateTime` : si `s` nul ou ne matche pas le motif datetime → `{ date: null, time: '' }`. Sinon découpe sur `T`, renvoie la date et `HH:MM` (5 premiers car. de la partie heure).
|
||||
- `composeDateTime(date, time)` : `${date}T${time}:00`. Si `time` vide → `${date}T00:00:00`.
|
||||
- `formatIsoDateTimeToDisplay` : si `!isValidIsoDateTime` → `''`. Sinon `${dd}/${mm}/${yyyy} ${hh}:${min}`.
|
||||
|
||||
## Tests
|
||||
|
||||
Colocalisés, Vitest + @vue/test-utils (jsdom), `vi.setSystemTime(new Date(2026, 4, 19))` pour déterminisme.
|
||||
|
||||
**`datetimeFormat.test.ts`** (helpers purs) :
|
||||
- `isValidIsoDateTime` : accepte `"2026-05-20T14:30:00"` ; rejette `"2026-05-20"`, `"2026-13-01T00:00:00"`, `"2026-05-20T24:00:00"`, `"2026-05-20T14:60:00"`, `""`, format sans secondes.
|
||||
- `formatIsoDateTimeToDisplay` : `"2026-05-20T14:30:00"` → `"20/05/2026 14:30"` ; nul/invalide → `''`.
|
||||
- `splitDateTime` : `"2026-05-20T14:30:00"` → `{ date: "2026-05-20", time: "14:30" }` ; `null` → `{ date: null, time: '' }` ; `"2026-05-20"` (date seule, non datetime) → `{ date: null, time: '' }`.
|
||||
- `composeDateTime` : `("2026-05-20", "14:30")` → `"2026-05-20T14:30:00"` ; `("2026-05-20", "")` → `"2026-05-20T00:00:00"`.
|
||||
|
||||
**`DateTime.test.ts`** (composant) :
|
||||
- Rendu : champ présent, placeholder `JJ/MM/AAAA HH:MM`, `displayValue` correct quand `modelValue` fourni.
|
||||
- Ouverture popover au clic → `MonthGrid` + `input[type=time]` visibles.
|
||||
- Clic sur un jour sans heure → émet `"<iso>T00:00:00"`, popover reste ouvert.
|
||||
- Clic sur un jour avec `pendingTime` réglé d'abord → émet `"<iso>T<pendingTime>:00"`.
|
||||
- Changement de l'heure avec date déjà choisie → émet `"<date>T<nouvelleHeure>:00"`.
|
||||
- Changement de l'heure sans date → **aucune émission**.
|
||||
- `clearable` : croix émet `null`.
|
||||
- `min`/`max` datetime → jours hors bornes désactivés dans la grille.
|
||||
- Accessibilité : label lié, `aria-invalid` sur erreur.
|
||||
|
||||
## Livrables
|
||||
|
||||
1. `app/components/malio/date/composables/datetimeFormat.ts`
|
||||
2. `app/components/malio/date/composables/datetimeFormat.test.ts`
|
||||
3. `app/components/malio/date/DateTime.vue`
|
||||
4. `app/components/malio/date/DateTime.test.ts`
|
||||
5. Page playground `.playground/pages/composant/date/datetime.vue` + entrée nav (`playground.nav.ts`)
|
||||
6. Story `app/story/date/dateTime.story.vue`
|
||||
7. `COMPONENTS.md` (section MalioDateTime)
|
||||
8. `CHANGELOG.md` (ligne sous `### Added`)
|
||||
|
||||
## Hors périmètre (intérim)
|
||||
|
||||
- Sélecteur d'heure dédié / maquette → itération ultérieure ; on remplacera le bloc `<input type="time">` isolé.
|
||||
- Bornes horaires (min/max sur l'heure).
|
||||
- Pas de minutes configurable (granularité native du navigateur).
|
||||
- Secondes dans l'UI.
|
||||
Reference in New Issue
Block a user