Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 747285ae3f | |||
| 26759395f9 | |||
| c6bca756f1 | |||
| f797c1c8a0 | |||
| b2c6f33e38 | |||
| ccd84d6d4a |
@@ -47,6 +47,20 @@ const paginatedItems = computed(() => {
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
alert(`Clic sur ${item.nom} ${item.prenom} (id: ${item.id})`)
|
||||
}
|
||||
|
||||
const bigPage = ref(1)
|
||||
const bigPerPage = ref(10)
|
||||
const bigItems = Array.from({ length: 310 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
nom: `Nom ${i + 1}`,
|
||||
prenom: `Prénom ${i + 1}`,
|
||||
ville: ['Paris', 'Lyon', 'Marseille'][i % 3],
|
||||
montant: 500 + i * 7,
|
||||
}))
|
||||
const bigPaginated = computed(() => {
|
||||
const start = (bigPage.value - 1) * bigPerPage.value
|
||||
return bigItems.slice(start, start + bigPerPage.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,5 +102,21 @@ function onRowClick(item: Record<string, unknown>) {
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="mb-6 text-xl font-bold">Gros volume (31 pages) — saut de page</h2>
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="bigPaginated"
|
||||
:total-items="bigItems.length"
|
||||
v-model:page="bigPage"
|
||||
v-model:per-page="bigPerPage"
|
||||
>
|
||||
<template #cell-montant="{ item }">
|
||||
<strong>{{ item.montant }} €</strong>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
<p class="mt-3 text-sm text-gray-500">Page courante : {{ bigPage }} / {{ Math.ceil(bigItems.length / bigPerPage) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,15 +13,6 @@
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur (ISO) : <code>{{ value ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date (saisie clavier)"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -94,5 +85,4 @@ const readonlyFilledDate = ref<string | null>('2026-06-15')
|
||||
const value = ref<string | null>(null)
|
||||
const erpValue = ref<string | null>(null)
|
||||
const bounded = ref<string | null>(null)
|
||||
const editableValue = ref<string | null>(null)
|
||||
</script>
|
||||
|
||||
@@ -14,17 +14,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<div class="mt-2 rounded border p-3 text-sm">
|
||||
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Désactivé</h2>
|
||||
<MalioInputAmount
|
||||
@@ -88,5 +77,4 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const readonlyFilledAmount = ref('1250.00')
|
||||
const bigValue = ref('1234567.89')
|
||||
</script>
|
||||
|
||||
@@ -14,20 +14,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
|
||||
<div class="space-y-3">
|
||||
<MalioInputEmail
|
||||
v-for="(email, index) in emails"
|
||||
:key="index"
|
||||
v-model="emails[index]"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="emails.push('')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Icône à gauche</h2>
|
||||
<MalioInputEmail
|
||||
@@ -141,7 +127,6 @@ import { computed, ref } from 'vue'
|
||||
|
||||
const readonlyFilledEmail = ref('contact@malio.fr')
|
||||
const emailValue = ref('')
|
||||
const emails = ref<string[]>([''])
|
||||
const dynamicEmail = ref('')
|
||||
const requiredEmail = ref('')
|
||||
const lowercaseEmail = ref('')
|
||||
|
||||
@@ -14,17 +14,6 @@
|
||||
<p class="mt-2 text-sm text-gray-500">Valeur : {{ uploadValue || '(aucun)' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Clearable (croix pour vider)</h2>
|
||||
<MalioInputUpload
|
||||
v-model="clearableUpload"
|
||||
label="Téléverser un document"
|
||||
clearable
|
||||
@clear="onClearUpload"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-gray-500">Valeur : {{ clearableUpload || '(aucun)' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec accept (PDF)</h2>
|
||||
<MalioInputUpload
|
||||
@@ -105,11 +94,6 @@ import { computed, ref } from 'vue'
|
||||
const readonlyFilledUpload = ref('document.pdf')
|
||||
const uploadValue = ref('')
|
||||
const dynamicUpload = ref('')
|
||||
const clearableUpload = ref('rapport-2026.pdf')
|
||||
|
||||
const onClearUpload = () => {
|
||||
clearableUpload.value = ''
|
||||
}
|
||||
|
||||
const dynamicError = computed(() => {
|
||||
if (!dynamicUpload.value) return ''
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<MalioSelectCheckbox
|
||||
v-model="labelValue"
|
||||
:options="options"
|
||||
:display-tag="true"
|
||||
displayTag="true"
|
||||
empty-option-label=" "
|
||||
/>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<MalioSelectCheckbox
|
||||
v-model="labelValue1"
|
||||
:options="options"
|
||||
:display-tag="true"
|
||||
displayTag="true"
|
||||
label="Pays"
|
||||
empty-option-label=" "
|
||||
/>
|
||||
|
||||
+1
-14
@@ -41,23 +41,13 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
* Token Tailwind partagé `w-m-btn-action` (150px) exposé via `tailwind.config.ts` + CSS var `--m-btn-action-width` dans `malio.css` — utilisable côté consommateur pour les boutons d'action (`<MalioButton button-class="w-m-btn-action" />`), themable en redéfinissant la CSS var
|
||||
* [#MUI-41] Prop `required` cohérente + astérisque rouge dans le label sur la famille formulaire (Select, SelectCheckbox, InputUpload, InputRichText gagnent la prop)
|
||||
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
|
||||
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
|
||||
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
|
||||
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
|
||||
* [#MUI-42] Anneau de focus clavier standardisé (`outline` 2px `m-primary`, offset 2px) affiché **uniquement** à la navigation clavier (jamais au clic souris), sur l'ensemble des champs et contrôles : inputs (Text, Email, Password, Phone, Amount, Number + boutons ±, Upload, TextArea, Autocomplete), Select, SelectCheckbox, famille Date (Date, DateRange, DateTime, DateWeek), Button, ButtonIcon. Mécanique : composable `useKbdFocusRing` (détection de modalité clavier/souris) + utilitaires CSS `.m-focus-ring` (éléments à `:focus-visible` natif) et `.m-focus-ring-kbd` (champs texte, où `:focus-visible` se déclenche aussi à la souris)
|
||||
* [#MUI-42] Anneau « combo » : quand un dropdown / calendrier est ouvert (Autocomplete, Select, SelectCheckbox, Date), l'anneau entoure le champ **et** la liste / le calendrier d'un seul tenant, adapté au sens d'ouverture (utilitaires `.m-combo-ring-top` / `.m-combo-ring-bottom`)
|
||||
* [#MUI-42] Navigation clavier WAI-ARIA APG sur les listes déroulantes : Select et SelectCheckbox gagnent la navigation (flèches, Home/End, Entrée/Espace, Échap, Tab — absente jusque-là), avec scroll automatique de l'option active et `aria-activedescendant` ; InputAutocomplete complété (scroll auto, ArrowUp ouvre sur la dernière option, Home/End, Tab ferme)
|
||||
* [#MUI-42] SelectCheckbox : la ligne « Tout sélectionner » est intégrée à la navigation clavier ; le clic sur toute la ligne d'option (et plus seulement le label) coche/décoche
|
||||
* [#MUI-42] InputUpload : prop `clearable` (croix `mdi:close` focusable qui vide le champ + event `clear`) et ouverture du sélecteur de fichier au clavier (Entrée / Espace)
|
||||
* [#MUI-42] Famille Date : ouverture du calendrier au clavier (Entrée / Espace), fermeture par Échap
|
||||
|
||||
### Changed
|
||||
* DataTable : libellés de pagination en français — `Préc.` / `Suiv.` (étaient `Prev` / `Next`) ; aria-labels déjà en français inchangés.
|
||||
* DataTable : pagination compacte avec saut de page — `‹ Préc. Page [n] / N Suiv. ›` (remplace les numéros + `…`). Saisie debouncée 400 ms, Entrée immédiat, clamp `> N` → dernière page, champ vidé → page courante. Labels `Préc.` / `Suiv.`.
|
||||
* MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`).
|
||||
* DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`).
|
||||
* Select : nouvelle prop `fieldClass` pour surcharger les classes du field (notamment la hauteur `h-[40px]` jusqu'ici codée en dur) ; utilisée par le DataTable pour passer le sélecteur de perPage à `30px`.
|
||||
* [#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`.
|
||||
* [#MUI-42] Button / ButtonIcon : l'anneau de focus passe du halo `ring-2 ring-m-primary/50` à l'anneau standard `.m-focus-ring` (outline plein, offset 2px), pour l'homogénéité avec les autres composants.
|
||||
|
||||
### Fixed
|
||||
* DataTable : pagination réalignée verticalement après l'introduction du `min-h-[1rem]` du Select — la barre pagination passe en `items-center`, et le MalioSelect du sélecteur de perPage est encapsulé dans un wrapper `h-12` qui borne sa taille flex à la hauteur du field (le slot vide déborde invisiblement en dessous). Span « Lignes : » et boutons Prev/Page/Next sont désormais centrés exactement sur le field (y=24)
|
||||
@@ -72,6 +62,3 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
* InputAutocomplete : suppression de 4 sources de saut visuel au focus / ouverture (extra translate label, padding `grow-height:focus`, `focus:pl-[11px]`, `!border-b-0` remplacé par `!border-b-transparent`)
|
||||
* Select / SelectCheckbox : mêmes correctifs anti-saut (suppression du padding `grow-height:focus` et remplacement de `!border-b-0` / `!border-t-0` par leurs variantes `transparent`)
|
||||
* MalioButton : largeur par défaut alignée sur `w-[200px]` (au lieu de `w-[240px]`) pour correspondre au sizing des formulaires de l'app
|
||||
* [#MUI-42] RadioButton : ajout d'un focus visible au clavier (`outline` 2px `m-primary`, offset 2px) — l'input en `appearance-none` n'avait aucun indicateur de focus, seul l'`outline: auto 1px` du navigateur restait, quasi invisible. La navigation native (Tab entre groupes, flèches dans le groupe) reste inchangée
|
||||
* [#MUI-42] Checkbox : ajout d'un focus visible au clavier sur la case (`outline` 2px `m-primary`, offset 2px) — l'input réel est masqué (`clip-path`), aucun indicateur n'apparaissait à la tabulation
|
||||
* [#MUI-42] Select : le focus reste sur le bouton après sélection (un `blur()` renvoyait le focus au `body`, cassant la tabulation clavier — un Tab repartait du haut de page)
|
||||
|
||||
+5
-34
@@ -4,8 +4,6 @@ Tous les composants sont auto-importés avec le préfixe `Malio`. Utiliser `v-mo
|
||||
|
||||
> **Champ obligatoire :** sur les composants de formulaire, la prop `required` ajoute un astérisque rouge dans le label. C'est un repère visuel ; la sémantique « obligatoire » est portée par l'attribut natif `required` ou `aria-required`.
|
||||
|
||||
> **Focus clavier :** tous les champs et contrôles affichent un anneau de focus (`outline` 2px `m-primary`, offset 2px) **uniquement** à la navigation clavier (Tab), jamais au clic souris. Sur les composants à dropdown/calendrier ouverts, l'anneau entoure le champ et la liste d'un seul tenant. Voir la note « Clavier » de chaque composant pour la navigation détaillée.
|
||||
|
||||
---
|
||||
|
||||
## MalioInputText
|
||||
@@ -96,25 +94,19 @@ Champ email (`type="email"` + `inputmode="email"`) avec icône `mdi:email-outlin
|
||||
| `iconPosition` | `'left' \| 'right'` | `'right'` | Position de l'icône |
|
||||
| `iconSize` | `string \| number` | `24` | Taille icône |
|
||||
| `iconColor` | `string` | `'text-m-muted'` | Classe couleur icône |
|
||||
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
|
||||
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
|
||||
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
|
||||
| `inputClass` | `string` | `''` | Classes CSS input |
|
||||
| `labelClass` | `string` | `''` | Classes CSS label |
|
||||
| `groupClass` | `string` | `''` | Classes CSS conteneur |
|
||||
|
||||
> **Sanitisation à la saisie :** tous les espaces sont supprimés automatiquement au fil de la frappe (sans masque). Avec `lowercase=true`, la valeur est également convertie en minuscules à la frappe. La validation du format (ex. présence d'un `@`) reste à la charge du parent via la prop `error` ou la couche de validation.
|
||||
|
||||
**Events :**
|
||||
- `update:modelValue(value: string)`
|
||||
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
|
||||
```vue
|
||||
<MalioInputEmail v-model="email" label="Adresse email" />
|
||||
<MalioInputEmail v-model="email" label="Email" autocomplete="email" />
|
||||
<MalioInputEmail v-model="email" label="Email" :icon-name="''" />
|
||||
<MalioInputEmail v-model="email" label="Email" error="Adresse email invalide" />
|
||||
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
|
||||
```
|
||||
|
||||
---
|
||||
@@ -202,7 +194,7 @@ Champ de saisie assistée (typeahead / combobox) : l'utilisateur tape pour filtr
|
||||
- `select(option: Option \| null)` — émis avec l'objet `Option` complet (utile pour récupérer aussi le `label`)
|
||||
- `create(value: string)` — émis quand `allowCreate=true` et que l'utilisateur valide une valeur libre
|
||||
|
||||
**Clavier (WAI-ARIA APG) :** `↓` ouvre / option suivante, `↑` précédente (ou ouvre sur la dernière option si fermé), `Début`/`Fin`, scroll automatique de l'option active, `Entrée` sélection (ou création), `Échap` annule, `Tab` ferme. Anneau de focus clavier (combo champ + liste à l'ouverture).
|
||||
**Clavier :** `↓` / `↑` navigation, `Entrée` sélection (ou création), `Échap` ferme le dropdown.
|
||||
|
||||
```vue
|
||||
<!-- Usage statique (filtrage côté client via local-filter) -->
|
||||
@@ -244,8 +236,6 @@ async function onSearchClients(query: string) {
|
||||
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
|
||||
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string \| null` | `undefined` | Valeur (v-model) |
|
||||
@@ -261,8 +251,6 @@ L'affichage est groupé à la française (`1 234 567,89` : espace pour les milli
|
||||
```vue
|
||||
<MalioInputAmount v-model="montant" label="Montant TTC" />
|
||||
<MalioInputAmount v-model="prix" label="Prix" error="Montant invalide" />
|
||||
<MalioInputAmount v-model="gros" label="Budget" />
|
||||
<!-- saisie 1234567.89 → affiché "1 234 567,89", modelValue "1234567.89" -->
|
||||
```
|
||||
|
||||
---
|
||||
@@ -364,20 +352,16 @@ Champ d'upload de fichier.
|
||||
| `label` | `string` | `''` | Label |
|
||||
| `accept` | `string` | `''` | Types de fichiers acceptés |
|
||||
| `displayIcon` | `boolean` | `true` | Afficher l'icône |
|
||||
| `clearable` | `boolean` | `false` | Affiche une croix (`mdi:close`) focusable qui vide le champ quand un fichier est sélectionné |
|
||||
| `disabled` | `boolean` | `false` | Désactivé |
|
||||
| `readonly` | `boolean` | `false` | Champ en lecture seule (bordure noire, pas de focus bleu/grossissement, label/icône gris→noir selon rempli). |
|
||||
| `required` | `boolean` | `false` | Champ requis (astérisque rouge dans le label) |
|
||||
| `error` | `string` | `''` | Message d'erreur |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
|
||||
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`, `clear()`
|
||||
|
||||
**Clavier :** `Entrée` / `Espace` ouvrent le sélecteur de fichier. La croix `clearable` est focusable (anneau clavier, `Entrée`/`Espace`).
|
||||
**Events :** `update:modelValue(value: string)`, `file-selected(file: File)`
|
||||
|
||||
```vue
|
||||
<MalioInputUpload v-model="fileName" label="Document" accept=".pdf,.doc" @file-selected="onFile" />
|
||||
<MalioInputUpload v-model="fileName" label="Document" clearable @clear="onClear" />
|
||||
```
|
||||
|
||||
---
|
||||
@@ -410,8 +394,6 @@ Liste déroulante.
|
||||
**Events :** `update:modelValue(value: string | number | null)`
|
||||
**Slots :** `icon` (icône dropdown custom)
|
||||
|
||||
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto de l'option active), `Début`/`Fin`, `Entrée`/`Espace` sélectionnent, `Échap`/`Tab` ferment. Le focus reste sur le bouton après sélection. Anneau de focus clavier (combo bouton + liste à l'ouverture, adapté au sens haut/bas).
|
||||
|
||||
```vue
|
||||
<MalioSelect v-model="pays" label="Pays" :options="[{ value: 'FR', text: 'France' }, { value: 'BE', text: 'Belgique' }]" />
|
||||
<MalioSelect v-model="ville" label="Ville" :options="villes" empty-option-label="Choisir..." />
|
||||
@@ -440,8 +422,6 @@ Liste déroulante multi-sélection avec checkboxes.
|
||||
|
||||
**Events :** `update:modelValue(value: (string | number)[])`
|
||||
|
||||
**Clavier (WAI-ARIA APG) :** `↓`/`↑`/`Entrée`/`Espace` ouvrent ; liste ouverte → `↑↓` naviguent (scroll auto), `Début`/`Fin`, `Entrée`/`Espace` cochent/décochent l'option active (la liste **reste ouverte**), `Échap`/`Tab` ferment. La ligne « Tout sélectionner » est navigable au clavier. Le clic sur toute la ligne (pas que le label) coche/décoche. Anneau de focus clavier (combo bouton + liste à l'ouverture).
|
||||
|
||||
```vue
|
||||
<MalioSelectCheckbox v-model="competences" label="Compétences" :options="skills" :display-tag="true" />
|
||||
<MalioSelectCheckbox v-model="sites" label="Sites" :options="sitesList" :display-select-all="true" />
|
||||
@@ -465,8 +445,6 @@ Case à cocher.
|
||||
|
||||
**Events :** `update:modelValue(value: boolean)`
|
||||
|
||||
**Clavier :** `Espace` coche/décoche. Focus clavier visible sur la case (`outline` 2px `m-primary`).
|
||||
|
||||
```vue
|
||||
<MalioCheckbox v-model="accepte" label="J'accepte les conditions" />
|
||||
<MalioCheckbox v-model="newsletter" label="Newsletter" disabled />
|
||||
@@ -490,8 +468,6 @@ Bouton radio (à utiliser en groupe avec le même `name`).
|
||||
|
||||
**Events :** `update:modelValue(value: string | number | boolean | null)`
|
||||
|
||||
**Clavier :** comportement natif d'un groupe radio (options partageant le même `name`) — `Tab` / `Maj+Tab` entre/sort du groupe (1 seul arrêt par groupe), `↑↓←→` déplacent la sélection entre les options d'un même groupe. Focus clavier visible (`outline` 2px `m-primary`).
|
||||
|
||||
```vue
|
||||
<MalioRadioButton v-model="civilite" name="civilite" value="M" label="Monsieur" />
|
||||
<MalioRadioButton v-model="civilite" name="civilite" value="Mme" label="Madame" />
|
||||
@@ -505,8 +481,6 @@ 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.
|
||||
|
||||
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`).
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `modelValue` | `string \| null` | `undefined` | Date ISO `"YYYY-MM-DD"` (v-model) |
|
||||
@@ -523,20 +497,15 @@ Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'
|
||||
| `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 |
|
||||
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
|
||||
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
|
||||
| `reserveMessageSpace` | `boolean` | `true` | Réserve l'espace de la ligne message sous le champ (min-h) même sans message. `false` = la ligne ne prend de place que s'il y a un hint/error/success. |
|
||||
| `inputClass` / `labelClass` / `groupClass` | `string` | `''` | Override des classes |
|
||||
|
||||
**Events :** `update:modelValue(value: string | null)`
|
||||
|
||||
**Clavier :** `Entrée` / `Espace` ouvrent le calendrier, `Échap` ferme. Anneau de focus clavier (combo champ + calendrier à l'ouverture). La croix d'effacement est focusable. _(Comportement partagé par DateRange, DateTime, DateWeek via le shell CalendarField.)_
|
||||
|
||||
```vue
|
||||
<MalioDate v-model="date" label="Date de naissance" />
|
||||
<!-- date === "2026-05-20" -->
|
||||
<MalioDate v-model="rdv" label="Rendez-vous" :min="todayIso" :max="maxIso" />
|
||||
<MalioDate v-model="date" label="Date de naissance" editable />
|
||||
```
|
||||
|
||||
---
|
||||
@@ -988,6 +957,8 @@ Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'acces
|
||||
|
||||
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
|
||||
|
||||
**Pagination :** forme compacte `‹ Préc. Page [n] / N Suiv. ›`. Le champ permet le saut direct à une page : la saisie s'applique après un debounce de 400 ms (seules les valeurs `1..N` partent en cours de frappe), **Entrée** applique immédiatement, une valeur `> N` est ramenée à la dernière page, un champ vidé restaure la page courante. `v-model:page` inchangé.
|
||||
|
||||
| Prop | Type | Défaut | Description |
|
||||
|------|------|--------|-------------|
|
||||
| `id` | `string` | auto | Identifiant HTML |
|
||||
|
||||
@@ -2,41 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
/* Anneau de focus clavier standard (navigation au Tab), invisible à la souris.
|
||||
Deux déclencheurs, même rendu :
|
||||
- .m-focus-ring → s'appuie sur :focus-visible natif. Pour les éléments
|
||||
où :focus-visible se limite déjà au clavier (boutons,
|
||||
onglets, tuiles, checkbox/radio…).
|
||||
- .m-focus-ring-kbd → classe ajoutée en JS (via useKbdFocusRing) uniquement
|
||||
quand le focus vient du clavier. Pour les champs texte,
|
||||
où :focus-visible natif se déclenche aussi à la souris.
|
||||
Le `:focus` sur .m-focus-ring-kbd élève la spécificité pour passer devant le
|
||||
`outline-none` des inputs. */
|
||||
.m-focus-ring:focus-visible,
|
||||
.m-focus-ring-kbd:focus {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Anneau de focus clavier pour un combobox ouvert (input + liste) : l'anneau
|
||||
entoure le bloc entier d'un seul tenant. L'input porte le contour haut+côtés,
|
||||
la liste le contour côtés+bas ; la jonction (bas de l'input / haut de la liste)
|
||||
reste sans contour pour un raccord sans couture. */
|
||||
.m-combo-ring-top {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 -2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
.m-combo-ring-bottom {
|
||||
box-shadow:
|
||||
-2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
2px 0 0 0 rgb(var(--m-primary) / 1),
|
||||
0 2px 0 0 rgb(var(--m-primary) / 1);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* ── Globales ── */
|
||||
|
||||
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 m-focus-ring',
|
||||
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
||||
variantClasses.value,
|
||||
props.buttonClass,
|
||||
),
|
||||
|
||||
@@ -52,7 +52,7 @@ const isFilled = computed(() => props.variant === 'filled')
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 m-focus-ring',
|
||||
'inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
|
||||
isFilled.value
|
||||
? props.disabled
|
||||
? 'bg-m-disabled text-white cursor-not-allowed'
|
||||
|
||||
@@ -180,11 +180,6 @@ const onChange = (event: Event) => {
|
||||
border-color: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.inp-cbx:focus-visible + .cbx span:first-child {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cbx span:first-child svg {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { DefineComponent } from 'vue'
|
||||
@@ -189,24 +189,6 @@ describe('MalioDataTable', () => {
|
||||
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders all pages when totalPages <= 5', () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10 })
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('highlights current page', () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
|
||||
expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('emits update:page on page button click', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||
await wrapper.find('[data-test="page-3"]').trigger('click')
|
||||
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
|
||||
})
|
||||
|
||||
it('Prev button is disabled on page 1', () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
|
||||
@@ -229,26 +211,6 @@ describe('MalioDataTable', () => {
|
||||
expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
|
||||
})
|
||||
|
||||
it('shows ellipsis for truncated pages (> 5 pages)', () => {
|
||||
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||
const ellipsis = wrapper.findAll('[aria-hidden="true"]')
|
||||
expect(ellipsis.length).toBeGreaterThan(0)
|
||||
expect(ellipsis[0].text()).toBe('…')
|
||||
})
|
||||
|
||||
it('always shows first and last page when > 5 pages', () => {
|
||||
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||
expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows 1 neighbor on each side of current page', () => {
|
||||
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
|
||||
expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('pagination nav has aria-label', () => {
|
||||
const wrapper = mountComponent({ totalItems: 30 })
|
||||
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
|
||||
@@ -265,6 +227,80 @@ describe('MalioDataTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pagination — saut de page (champ)', () => {
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers() })
|
||||
|
||||
it('affiche la page courante et le total dans le champ', () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 16 })
|
||||
expect((wrapper.find('[data-test="page-input"]').element as HTMLInputElement).value).toBe('16')
|
||||
expect(wrapper.find('[data-test="total-pages"]').text()).toBe('31')
|
||||
})
|
||||
|
||||
it('émet update:page après le debounce pour une valeur valide', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('16')
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
vi.advanceTimersByTime(400)
|
||||
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([16])
|
||||
})
|
||||
|
||||
it('n\'émet pas avant la fin du debounce', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
await wrapper.find('[data-test="page-input"]').setValue('16')
|
||||
vi.advanceTimersByTime(399)
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Entrée applique immédiatement', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('16')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([16])
|
||||
})
|
||||
|
||||
it('clampe une valeur > N à la dernière page (Entrée)', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('50')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([31])
|
||||
})
|
||||
|
||||
it('restaure la page courante quand le champ est vidé au blur', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 5 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('5')
|
||||
})
|
||||
|
||||
it('n\'émet pas pour 0 et restaure la page courante (Entrée)', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 5 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('0')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('5')
|
||||
})
|
||||
|
||||
it('retire les caractères non numériques à la frappe', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('1a2b')
|
||||
expect((input.element as HTMLInputElement).value).toBe('12')
|
||||
})
|
||||
|
||||
it('resynchronise le champ quand la prop page change', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
await wrapper.setProps({ page: 7 })
|
||||
expect((wrapper.find('[data-test="page-input"]').element as HTMLInputElement).value).toBe('7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Per-page selector', () => {
|
||||
it('emits update:per-page and reset page to 1 on change', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
|
||||
|
||||
@@ -86,29 +86,25 @@
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page précédente"
|
||||
data-test="prev-button"
|
||||
@click="goToPage(page - 1)"
|
||||
@click="changePage(page - 1)"
|
||||
/>
|
||||
|
||||
<template v-for="(p, idx) in visiblePages" :key="idx">
|
||||
<span
|
||||
v-if="p === '...'"
|
||||
class="px-1 text-sm text-m-muted"
|
||||
aria-hidden="true"
|
||||
>…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="inline-flex h-[30px] min-w-[2.5rem] items-center justify-center rounded px-2 text-sm transition-colors"
|
||||
:class="p === page
|
||||
? 'bg-m-btn-primary text-white font-semibold'
|
||||
: 'text-m-text hover:bg-m-bg'"
|
||||
:aria-current="p === page ? 'page' : undefined"
|
||||
:data-test="`page-${p}`"
|
||||
@click="goToPage(p)"
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<label :for="pageInputId" class="text-m-muted">Page</label>
|
||||
<input
|
||||
:id="pageInputId"
|
||||
v-model="pageInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
aria-label="Aller à la page"
|
||||
data-test="page-input"
|
||||
class="h-[30px] w-[58px] rounded-malio border border-m-border text-center text-sm text-m-text outline-none focus:border-m-primary"
|
||||
@input="onPageInput"
|
||||
@keydown.enter="commitPageInput"
|
||||
@blur="commitPageInput"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</template>
|
||||
<span class="text-m-muted">/ <span data-test="total-pages">{{ totalPages }}</span></span>
|
||||
</span>
|
||||
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
@@ -117,7 +113,7 @@
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page suivante"
|
||||
data-test="next-button"
|
||||
@click="goToPage(page + 1)"
|
||||
@click="changePage(page + 1)"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -125,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs, useId } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount, useAttrs, useId } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import MalioSelect from '../select/Select.vue'
|
||||
import MalioButton from '../button/Button.vue'
|
||||
@@ -173,6 +169,15 @@ const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))
|
||||
|
||||
const PAGE_JUMP_DEBOUNCE = 400
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const pageInputId = computed(() => `${componentId.value}-page-input`)
|
||||
const pageInput = ref(String(props.page))
|
||||
|
||||
watch(() => props.page, (p) => { pageInput.value = String(p) })
|
||||
|
||||
onBeforeUnmount(() => { if (debounceTimer) clearTimeout(debounceTimer) })
|
||||
|
||||
const perPageSelectOptions = computed(() =>
|
||||
props.perPageOptions.map(n => ({ label: String(n), value: n }))
|
||||
)
|
||||
@@ -184,42 +189,32 @@ function onPerPageChange(value: string | number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
function changePage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value && page !== props.page) {
|
||||
emit('update:page', page)
|
||||
}
|
||||
}
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = totalPages.value
|
||||
const current = props.page
|
||||
|
||||
if (total <= 5) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1)
|
||||
function onPageInput() {
|
||||
pageInput.value = pageInput.value.replace(/[^0-9]/g, '')
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (pageInput.value === '') return
|
||||
const n = Number(pageInput.value)
|
||||
if (n >= 1 && n <= totalPages.value) {
|
||||
debounceTimer = setTimeout(() => changePage(n), PAGE_JUMP_DEBOUNCE)
|
||||
}
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = []
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...')
|
||||
function commitPageInput() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
const raw = pageInput.value.trim()
|
||||
const n = Number(raw)
|
||||
if (raw === '' || n === 0 || Number.isNaN(n)) {
|
||||
pageInput.value = String(props.page)
|
||||
return
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(total - 1, current + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
if (total > 1) {
|
||||
pages.push(total)
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
const clamped = Math.min(Math.max(1, Math.round(n)), totalPages.value)
|
||||
changePage(clamped)
|
||||
pageInput.value = String(clamped)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -18,8 +18,6 @@ type DateProps = {
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -260,95 +258,4 @@ describe('MalioDate', () => {
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('saisie manuelle (editable)', () => {
|
||||
it('efface l\'erreur de saisie quand modelValue change de l\'extérieur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await wrapper.setProps({modelValue: '2026-05-19'})
|
||||
expect(wrapper.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('readonly')).toBeDefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
})
|
||||
|
||||
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('passe en erreur si la date saisie est hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('émet null sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await input.trigger('focus')
|
||||
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.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('valide et ferme le popover sur Entrée', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('utilise le message invalidMessage personnalisé', async () => {
|
||||
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('99/99/9999')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Format incorrect')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,16 +10,14 @@
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:hint="hint"
|
||||
:error="mergedError"
|
||||
:error="error"
|
||||
:success="success"
|
||||
:clearable="clearable"
|
||||
:editable="editable"
|
||||
:input-class="inputClass"
|
||||
:label-class="labelClass"
|
||||
:group-class="groupClass"
|
||||
v-bind="$attrs"
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
@clear="emit('update:modelValue', null)"
|
||||
>
|
||||
<template #default="{ currentMonth, currentYear, close }">
|
||||
<MonthGrid
|
||||
@@ -28,17 +26,17 @@
|
||||
:selected-date="modelValue ?? null"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
||||
/>
|
||||
</template>
|
||||
</CalendarField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {computed, watch} from 'vue'
|
||||
import CalendarField from './internal/CalendarField.vue'
|
||||
import MonthGrid from './internal/MonthGrid.vue'
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
|
||||
defineOptions({name: 'MalioDate', inheritAttrs: false})
|
||||
|
||||
@@ -58,8 +56,6 @@ const props = withDefaults(
|
||||
min?: string
|
||||
max?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -79,8 +75,6 @@ const props = withDefaults(
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
clearable: true,
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
@@ -91,38 +85,7 @@ const emit = defineEmits<{(e: 'update:modelValue', value: string | null): void}>
|
||||
|
||||
const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))
|
||||
|
||||
const internalError = ref('')
|
||||
const mergedError = computed(() => props.error || internalError.value)
|
||||
|
||||
const onCommit = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = parseDisplayToIso(trimmed)
|
||||
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
return
|
||||
}
|
||||
internalError.value = props.invalidMessage
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const onSelect = (iso: string, close: () => void) => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
close()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
internalError.value = ''
|
||||
if (val && !isValidIso(val) && import.meta.dev) {
|
||||
console.warn(`[MalioDate] modelValue invalide ignoré : "${val}"`)
|
||||
}
|
||||
|
||||
@@ -6,15 +6,14 @@
|
||||
>
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="maskaOptions"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
:readonly="inputReadonly"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="editable ? draft : displayValue"
|
||||
:value="displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
@@ -23,10 +22,6 @@
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
@focus="onFocus(); onKbdFocus()"
|
||||
@input="onInput"
|
||||
@blur="onBlur(); onKbdBlur()"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -42,7 +37,7 @@
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
data-test="clear"
|
||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||
class="text-m-muted hover:text-m-primary"
|
||||
aria-label="Effacer la date"
|
||||
@click.stop="emit('clear')"
|
||||
>
|
||||
@@ -66,7 +61,6 @@
|
||||
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)]"
|
||||
:class="keyboardFocused ? 'm-combo-ring-bottom' : ''"
|
||||
>
|
||||
<CalendarHeader
|
||||
:view-mode="viewMode"
|
||||
@@ -108,19 +102,14 @@
|
||||
import {computed, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import {vMaska} from 'maska/vue'
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
import MalioRequiredMark from '../../shared/RequiredMark.vue'
|
||||
import CalendarHeader from './CalendarHeader.vue'
|
||||
import MonthPicker from './MonthPicker.vue'
|
||||
import {useCalendarPopover} from '../composables/useCalendarPopover'
|
||||
import {useCalendarView} from '../composables/useCalendarView'
|
||||
import {useKbdFocusRing} from '../../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioCalendarField', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
displayValue: string
|
||||
@@ -136,7 +125,6 @@ const props = withDefaults(
|
||||
error?: string
|
||||
success?: string
|
||||
clearable?: boolean
|
||||
editable?: boolean
|
||||
inputClass?: string
|
||||
labelClass?: string
|
||||
groupClass?: string
|
||||
@@ -154,7 +142,6 @@ const props = withDefaults(
|
||||
error: '',
|
||||
success: '',
|
||||
clearable: true,
|
||||
editable: false,
|
||||
inputClass: '',
|
||||
labelClass: '',
|
||||
groupClass: '',
|
||||
@@ -162,32 +149,19 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear' | 'close'): void
|
||||
(e: 'commit', value: string): void
|
||||
}>()
|
||||
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const generatedId = useId()
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const draft = ref(props.displayValue)
|
||||
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined}))
|
||||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||
|
||||
watch(() => props.displayValue, (value) => {
|
||||
draft.value = value
|
||||
})
|
||||
|
||||
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.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||
)
|
||||
const isFilled = computed(() => props.displayValue.length > 0)
|
||||
const isReadonly = computed(() => props.readonly && !props.disabled)
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !props.readonly,
|
||||
@@ -202,13 +176,6 @@ watch(isOpen, (value) => {
|
||||
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (props.editable) {
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
@@ -217,56 +184,6 @@ const onFieldClick = () => {
|
||||
open()
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (props.disabled || props.readonly || !props.editable) return
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
draft.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
closePopover()
|
||||
}
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (isOpen.value) {
|
||||
e.preventDefault()
|
||||
closePopover()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.editable) {
|
||||
// En mode éditable, Entrée valide la saisie (Espace = caractère normal)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onEnter()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Mode non éditable : Entrée / Espace ouvre ou ferme le calendrier
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onFieldClick()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.syncTo, (value) => {
|
||||
if (isOpen.value) syncToIso(value)
|
||||
})
|
||||
@@ -293,7 +210,6 @@ const mergedInputClass = computed(() =>
|
||||
? 'border-m-success'
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
(!isReadonly.value && isOpen.value) ? 'border-m-primary !py-[9px] !rounded-b-none' : '',
|
||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||
props.inputClass,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue('12.5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('12,5')
|
||||
expect(wrapper.get('input').element.value).toBe('12.5')
|
||||
})
|
||||
|
||||
it('accepts commas but normalizes them to dots', async () => {
|
||||
@@ -106,7 +106,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue('0012,345abc')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['12.34'])
|
||||
expect(wrapper.get('input').element.value).toBe('12,34')
|
||||
expect(wrapper.get('input').element.value).toBe('12.34')
|
||||
})
|
||||
|
||||
it('normalizes a leading decimal separator', async () => {
|
||||
@@ -115,7 +115,7 @@ describe('MalioInputAmount', () => {
|
||||
await wrapper.get('input').setValue(',5')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['0.5'])
|
||||
expect(wrapper.get('input').element.value).toBe('0,5')
|
||||
expect(wrapper.get('input').element.value).toBe('0.5')
|
||||
})
|
||||
|
||||
it('keeps the normalized decimal value on blur', async () => {
|
||||
@@ -126,7 +126,7 @@ describe('MalioInputAmount', () => {
|
||||
await input.trigger('blur')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['12.5']])
|
||||
expect(input.element.value).toBe('12,5')
|
||||
expect(input.element.value).toBe('12.5')
|
||||
})
|
||||
|
||||
it('keeps integer values unchanged on blur', async () => {
|
||||
@@ -230,52 +230,4 @@ describe('MalioInputAmount', () => {
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567')
|
||||
})
|
||||
|
||||
it('groupe un grand montant avec décimales', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567,89')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('formate la valeur initiale (modelValue) en groupé', () => {
|
||||
const wrapper = mountInputAmount({modelValue: '1234567.89'})
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('12345')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('maxLength autorise une valeur à la limite', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('1234')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234')
|
||||
})
|
||||
|
||||
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
|
||||
const wrapper = mountInputAmount({maxLength: 4})
|
||||
|
||||
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
:autocomplete="autocomplete"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
:minlength="minLength"
|
||||
:disabled="disabled"
|
||||
:value="formattedValue"
|
||||
:value="currentValue"
|
||||
:readonly="readonly"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
@@ -20,7 +21,7 @@
|
||||
inputmode="decimal"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@focus="isFocused = true"
|
||||
@blur="onBlur"
|
||||
>
|
||||
|
||||
@@ -65,13 +66,9 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
|
||||
|
||||
defineOptions({name: 'MalioInputAmount', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -129,7 +126,6 @@ const isFocused = ref(false)
|
||||
const inputId = computed(() => props.id?.toString() || `malio-input-amount-${generatedId}`)
|
||||
const isControlled = computed(() => props.modelValue !== undefined)
|
||||
const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))
|
||||
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
|
||||
const hasError = computed(() => !!props.error)
|
||||
const hasSuccess = computed(() => !!props.success)
|
||||
const isFilled = computed(() => currentValue.value.trim().length > 0)
|
||||
@@ -149,7 +145,6 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -195,37 +190,40 @@ const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
|
||||
const normalizeAmount = (value: string) => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Keep the DOM input value, local state, and v-model emission in sync.
|
||||
const updateValue = (target: HTMLInputElement, value: string) => {
|
||||
target.value = value
|
||||
if (!isControlled.value) {
|
||||
localValue.value = value
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// Normalize while typing so the field never keeps invalid amount characters.
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const rawText = target.value
|
||||
const caret = target.selectionStart ?? rawText.length
|
||||
const model = normalizeAmount(rawText)
|
||||
|
||||
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
|
||||
if (props.maxLength != null && model.length > Number(props.maxLength)) {
|
||||
target.value = formattedValue.value
|
||||
const restored = Math.min(Math.max(0, caret - 1), formattedValue.value.length)
|
||||
target.setSelectionRange(restored, restored)
|
||||
return
|
||||
}
|
||||
|
||||
const display = formatGroupedAmount(model)
|
||||
const sig = countSignificant(rawText, caret)
|
||||
target.value = display
|
||||
const newCaret = caretFromSignificant(display, sig)
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = model
|
||||
}
|
||||
emit('update:modelValue', model)
|
||||
updateValue(target, normalizeAmount(target.value))
|
||||
}
|
||||
|
||||
// Keep the blur handler only for focus-driven UI state.
|
||||
const onBlur = () => {
|
||||
isFocused.value = false
|
||||
onKbdBlur()
|
||||
}
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onKbdBlur"
|
||||
@click="onInputClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
@@ -91,7 +90,6 @@
|
||||
: hasSuccess
|
||||
? 'border-m-success select-scrollbar-success'
|
||||
: 'border-m-primary select-scrollbar-primary',
|
||||
keyboardFocused ? 'm-combo-ring-bottom' : '',
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -152,16 +150,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputAutocomplete', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
value: string | number
|
||||
@@ -326,7 +321,6 @@ const labelPositionClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? (isOpen.value ? 'm-combo-ring-top' : 'm-focus-ring-kbd') : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -406,7 +400,6 @@ const onInput = (event: Event) => {
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
onKbdFocus()
|
||||
if (props.disabled || props.readonly) return
|
||||
isFocused.value = true
|
||||
isOpen.value = true
|
||||
@@ -453,20 +446,7 @@ const closeAndRevert = () => {
|
||||
isFocused.value = false
|
||||
}
|
||||
|
||||
// Garde l'option active visible dans la liste défilante quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (index < 0 || !isOpen.value) return
|
||||
await nextTick()
|
||||
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
// Tab : laisse le focus partir mais ferme la liste (et valide la saisie courante)
|
||||
if (event.key === 'Tab') {
|
||||
if (isOpen.value) closeAndCommit()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeAndRevert()
|
||||
@@ -499,25 +479,7 @@ const onKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
// Liste fermée : ouvre et place sur la dernière option (APG)
|
||||
if (!isOpen.value) {
|
||||
isOpen.value = true
|
||||
activeIndex.value = filteredOptions.value.length - 1
|
||||
return
|
||||
}
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
// Home / End : première / dernière option quand la liste est ouverte
|
||||
if (isOpen.value && event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (isOpen.value && event.key === 'End') {
|
||||
event.preventDefault()
|
||||
activeIndex.value = filteredOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ type InputEmailProps = {
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
lowercase?: boolean
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
reserveMessageSpace?: boolean
|
||||
}
|
||||
|
||||
@@ -318,78 +315,4 @@ describe('MalioInputEmail', () => {
|
||||
expect(msg.exists()).toBe(true)
|
||||
expect(msg.classes()).not.toContain('min-h-[1rem]')
|
||||
})
|
||||
|
||||
it('does not render add button by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders add button when addable is true', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits add event when add button is clicked', async () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit add when disabled', async () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not emit add when readonly', async () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables add button when disabled', () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the email icon to the left automatically when addable', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
const icon = wrapper.get('[data-test="icon"]')
|
||||
expect(icon.classes()).toContain('left-[10px]')
|
||||
expect(icon.classes()).not.toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('keeps the email icon on the right when addable is false', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('uses the default add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
|
||||
})
|
||||
|
||||
it('allows overriding the add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
type="email"
|
||||
inputmode="email"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -40,23 +40,6 @@
|
||||
:class="[iconStateClass, iconPositionClass]"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@click="onAdd"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="addIconName"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="add-icon"
|
||||
/>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<p
|
||||
v-if="reserveMessageSpace || hint || error || success"
|
||||
@@ -82,12 +65,9 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputEmail', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -108,9 +88,6 @@ const props = withDefaults(
|
||||
iconPosition?: 'left' | 'right'
|
||||
iconSize?: string | number
|
||||
iconColor?: string
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
lowercase?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
@@ -133,9 +110,6 @@ const props = withDefaults(
|
||||
success: '',
|
||||
iconSize: 24,
|
||||
iconColor: 'text-m-muted',
|
||||
addable: false,
|
||||
addIconName: 'mdi:plus',
|
||||
addButtonLabel: 'Ajouter une adresse email',
|
||||
lowercase: false,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
@@ -167,7 +141,6 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -204,14 +177,6 @@ const mergedLabelClass = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||
iconStateClass.value,
|
||||
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
|
||||
const describedBy = computed(() => {
|
||||
const ids: string[] = []
|
||||
if (props.hint && !hasSuccess.value && !hasError.value) ids.push(`${inputId.value}-hint`)
|
||||
@@ -222,7 +187,6 @@ const describedBy = computed(() => {
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'add'): void
|
||||
}>()
|
||||
|
||||
const sanitizeEmail = (v: string) => {
|
||||
@@ -258,38 +222,25 @@ const onInput = (event: Event) => {
|
||||
emit('update:modelValue', sanitized)
|
||||
}
|
||||
|
||||
const onAdd = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
emit('add')
|
||||
}
|
||||
|
||||
const effectiveIconPosition = computed(() =>
|
||||
props.addable && props.iconName ? 'left' : props.iconPosition,
|
||||
)
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
|
||||
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
|
||||
const parts: string[] = []
|
||||
if (leftIcon) parts.push('!pl-11')
|
||||
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||
return parts.join(' ')
|
||||
if (!props.iconName) return ''
|
||||
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
|
||||
})
|
||||
|
||||
const disabled = computed(() => props.disabled)
|
||||
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
|
||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="m-focus-ring rounded-malio"
|
||||
:disabled="isMinusDisabled"
|
||||
@click="decrement"
|
||||
>
|
||||
@@ -36,12 +35,11 @@
|
||||
inputmode="numeric"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="m-focus-ring rounded-malio"
|
||||
:disabled="isPlusDisabled"
|
||||
@click="increment"
|
||||
>
|
||||
@@ -75,12 +73,9 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputNumber', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -189,7 +184,6 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
' peer h-[22px] min-w-0 border bg-white text-center outline-none placeholder:text-transparent text-lg border-x-0 border-black',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
props.disabled ? 'cursor-not-allowed text-black/60' : 'cursor-text',
|
||||
hasError.value
|
||||
? 'border-m-danger focus:border-m-danger [&:not(:placeholder-shown)]:border-m-danger'
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
placeholder="_"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -70,12 +70,9 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputPassword', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -150,7 +147,6 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -85,12 +85,9 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputPhone', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -170,7 +167,6 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -69,12 +69,9 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputText', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -153,7 +150,6 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
textInput,
|
||||
showCounterComputed ? 'pb-6' : '',
|
||||
rounded,
|
||||
keyboardFocused ? 'm-focus-ring-kbd' : '',
|
||||
]"
|
||||
:required="required"
|
||||
:maxlength="maxLength"
|
||||
@@ -33,8 +32,8 @@
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -90,12 +89,9 @@
|
||||
import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputTextArea', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
|
||||
@@ -26,10 +26,8 @@
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="openFilePicker"
|
||||
@keydown.enter.prevent="openFilePicker"
|
||||
@keydown.space.prevent="openFilePicker"
|
||||
@focus="isFocused = true; onKbdFocus()"
|
||||
@blur="isFocused = false; onKbdBlur()"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
>
|
||||
|
||||
<label
|
||||
@@ -40,33 +38,17 @@
|
||||
{{ label }}<MalioRequiredMark v-if="required" />
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="displayIcon || showClear"
|
||||
class="absolute right-[10px] top-1/2 flex -translate-y-1/2 items-center gap-1"
|
||||
>
|
||||
<button
|
||||
v-if="showClear"
|
||||
type="button"
|
||||
data-test="clear"
|
||||
class="m-focus-ring rounded-malio text-m-muted hover:text-m-primary"
|
||||
aria-label="Retirer le fichier"
|
||||
@click.stop="onClear"
|
||||
>
|
||||
<IconifyIcon
|
||||
icon="mdi:close"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<IconifyIcon
|
||||
v-if="displayIcon"
|
||||
icon="mdi:cloud-arrow-up-outline"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="icon"
|
||||
:class="[iconStateClass, 'pointer-events-none']"
|
||||
/>
|
||||
</div>
|
||||
<IconifyIcon
|
||||
v-if="displayIcon"
|
||||
icon="mdi:cloud-arrow-up-outline"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="icon"
|
||||
:class="[
|
||||
iconStateClass,
|
||||
'pointer-events-none absolute right-[10px] top-1/2 -translate-y-1/2',
|
||||
]"
|
||||
/>
|
||||
|
||||
</div>
|
||||
<p
|
||||
@@ -93,12 +75,9 @@ import {computed, ref, useAttrs, useId} from 'vue'
|
||||
import { Icon as IconifyIcon } from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioInputUpload', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id?: string
|
||||
@@ -115,7 +94,6 @@ const props = withDefaults(
|
||||
displayIcon?: boolean
|
||||
accept?: string
|
||||
required?: boolean
|
||||
clearable?: boolean
|
||||
reserveMessageSpace?: boolean
|
||||
}>(),
|
||||
{
|
||||
@@ -133,7 +111,6 @@ const props = withDefaults(
|
||||
displayIcon: true,
|
||||
accept: '',
|
||||
required: false,
|
||||
clearable: false,
|
||||
reserveMessageSpace: true,
|
||||
},
|
||||
)
|
||||
@@ -166,7 +143,6 @@ const mergedGroupClass = computed(() =>
|
||||
const mergedInputClass = computed(() =>
|
||||
twMerge(
|
||||
'floating-input peer min-h-[40px] w-full border bg-white pl-3 pr-3 py-1 outline-none placeholder:text-transparent text-lg rounded-md cursor-pointer',
|
||||
keyboardFocused.value ? 'm-focus-ring-kbd' : '',
|
||||
isReadonly.value ? '' : 'grow-height',
|
||||
isReadonly.value
|
||||
? 'border-black'
|
||||
@@ -177,9 +153,7 @@ const mergedInputClass = computed(() =>
|
||||
: hasSuccess.value
|
||||
? 'border-m-success focus:border-m-success [&:not(:placeholder-shown)]:border-m-success'
|
||||
: isReadonly.value ? '' : 'focus:border-m-primary',
|
||||
showClear.value
|
||||
? (props.displayIcon ? '!pr-16' : '!pr-10')
|
||||
: (props.displayIcon ? '!pr-10' : ''),
|
||||
props.displayIcon ? '!pr-10' : '',
|
||||
isReadonly.value ? '' : 'focus:pl-[11px]',
|
||||
isReadonly.value ? 'cursor-default' : '',
|
||||
disabled.value ? 'cursor-not-allowed' : '',
|
||||
@@ -217,21 +191,8 @@ const describedBy = computed(() => {
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'file-selected', file: File): void
|
||||
(event: 'clear'): void
|
||||
}>()
|
||||
|
||||
const showClear = computed(() =>
|
||||
props.clearable && isFilled.value && !props.disabled && !isReadonly.value,
|
||||
)
|
||||
|
||||
const onClear = () => {
|
||||
if (props.disabled || isReadonly.value) return
|
||||
if (!isControlled.value) localValue.value = ''
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
emit('update:modelValue', '')
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
fileInputRef.value?.click()
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
|
||||
|
||||
describe('normalizeAmount', () => {
|
||||
it('garde le point décimal', () => {
|
||||
expect(normalizeAmount('12.5')).toBe('12.5')
|
||||
})
|
||||
it('convertit la virgule en point et nettoie', () => {
|
||||
expect(normalizeAmount('0012,345abc')).toBe('12.34')
|
||||
})
|
||||
it('normalise une décimale en tête', () => {
|
||||
expect(normalizeAmount(',5')).toBe('0.5')
|
||||
})
|
||||
it('retire les espaces', () => {
|
||||
expect(normalizeAmount('1 234 567')).toBe('1234567')
|
||||
})
|
||||
it('limite à 2 décimales', () => {
|
||||
expect(normalizeAmount('1234.567')).toBe('1234.56')
|
||||
})
|
||||
it('garde une décimale en cours de saisie', () => {
|
||||
expect(normalizeAmount('12.')).toBe('12.')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une saisie non numérique', () => {
|
||||
expect(normalizeAmount('abc')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(normalizeAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGroupedAmount', () => {
|
||||
it('groupe la partie entière par 3 avec des espaces', () => {
|
||||
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
|
||||
})
|
||||
it('utilise la virgule comme séparateur décimal', () => {
|
||||
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
|
||||
})
|
||||
it('affiche une virgule pour une décimale en cours', () => {
|
||||
expect(formatGroupedAmount('12.')).toBe('12,')
|
||||
})
|
||||
it('gère les valeurs sous 1000 sans séparateur', () => {
|
||||
expect(formatGroupedAmount('12')).toBe('12')
|
||||
})
|
||||
it('groupe avec une décimale en tête', () => {
|
||||
expect(formatGroupedAmount('0.5')).toBe('0,5')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une chaîne vide', () => {
|
||||
expect(formatGroupedAmount('')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(formatGroupedAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countSignificant', () => {
|
||||
it('compte les caractères hors espaces à gauche du curseur', () => {
|
||||
expect(countSignificant('1 234', 5)).toBe(4)
|
||||
})
|
||||
it('ignore un espace juste avant le curseur', () => {
|
||||
expect(countSignificant('1 234', 2)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('caretFromSignificant', () => {
|
||||
it('place le curseur après le n-ième caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
|
||||
})
|
||||
it('place le curseur en fin si on dépasse', () => {
|
||||
expect(caretFromSignificant('1 234', 10)).toBe(5)
|
||||
})
|
||||
it('place le curseur au début pour 0 caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234', 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
|
||||
export const normalizeAmount = (value: string): string => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
|
||||
export const formatGroupedAmount = (model: string): string => {
|
||||
if (model === '') return ''
|
||||
const hasDot = model.includes('.')
|
||||
const [integerPart = '', decimalPart = ''] = model.split('.')
|
||||
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
|
||||
}
|
||||
|
||||
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
|
||||
export const countSignificant = (str: string, upTo: number): number =>
|
||||
str.slice(0, upTo).replace(/ /g, '').length
|
||||
|
||||
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
|
||||
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||
if (sig <= 0) return 0
|
||||
let seen = 0
|
||||
for (let i = 0; i < display.length; i++) {
|
||||
if (display[i] !== ' ') seen++
|
||||
if (seen >= sig) return i + 1
|
||||
}
|
||||
return display.length
|
||||
}
|
||||
@@ -179,11 +179,6 @@ const onChange = (event: Event) => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.radio-control input[type='radio']:focus-visible {
|
||||
outline: 2px solid rgb(var(--m-primary) / 1);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.radio-control.is-error input[type='radio'] {
|
||||
border-color: rgb(var(--m-danger) / 1);
|
||||
}
|
||||
|
||||
@@ -37,24 +37,15 @@
|
||||
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
|
||||
rounded,
|
||||
textField,
|
||||
keyboardFocused
|
||||
? (isOpen
|
||||
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||
: 'm-focus-ring-kbd')
|
||||
: '',
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:aria-readonly="isReadonly || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
@focus="onKbdFocus"
|
||||
@blur="onKbdBlur"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -143,10 +134,7 @@
|
||||
? 'border-m-danger'
|
||||
: hasSuccess
|
||||
? 'border-m-success'
|
||||
: 'border-m-primary',
|
||||
keyboardFocused
|
||||
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||
: '',
|
||||
: 'border-m-primary'
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -196,16 +184,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioSelect', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number | null
|
||||
@@ -359,68 +344,7 @@ function toggle() {
|
||||
function select(value: string | number | null) {
|
||||
emit('update:modelValue', value)
|
||||
close()
|
||||
// On garde le focus sur le bouton après sélection (APG) : le focus ne doit pas
|
||||
// retomber sur le body. La souris le conserve déjà via @mousedown.prevent sur
|
||||
// les options ; au clavier le focus n'a jamais quitté le bouton.
|
||||
buttonRef.value?.focus()
|
||||
}
|
||||
|
||||
// Garde l'option active visible quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (index < 0 || !isOpen.value) return
|
||||
await nextTick()
|
||||
document.getElementById(optionId(index))?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
// Tab : laisse le focus partir mais ferme la liste
|
||||
if (e.key === 'Tab') {
|
||||
if (isOpen.value) close()
|
||||
return
|
||||
}
|
||||
|
||||
// Liste fermée : ouverture au clavier
|
||||
if (!isOpen.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Liste ouverte
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const opt = normalizedOptions.value[activeIndex.value]
|
||||
if (opt) select(opt.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = normalizedOptions.value.length - 1
|
||||
}
|
||||
buttonRef.value?.blur()
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
|
||||
@@ -37,24 +37,15 @@
|
||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||
rounded,
|
||||
textField,
|
||||
keyboardFocused
|
||||
? (isOpen
|
||||
? (openDirection === 'down' ? 'm-combo-ring-top' : 'm-combo-ring-bottom')
|
||||
: 'm-focus-ring-kbd')
|
||||
: '',
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-controls="listboxId"
|
||||
:aria-activedescendant="!isOpen ? undefined : (activeIndex === -1 ? selectAllId : (activeIndex >= 0 ? optionId(activeIndex) : undefined))"
|
||||
:aria-invalid="hasError"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-required="required || undefined"
|
||||
:aria-readonly="isReadonly || undefined"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
@keydown="onKeydown"
|
||||
@focus="onKbdFocus"
|
||||
@blur="onKbdBlur"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
@@ -171,10 +162,7 @@
|
||||
? 'border-m-danger'
|
||||
: hasSuccess
|
||||
? 'border-m-success'
|
||||
: 'border-m-primary',
|
||||
keyboardFocused
|
||||
? (openDirection === 'down' ? 'm-combo-ring-bottom' : 'm-combo-ring-top')
|
||||
: '',
|
||||
: 'border-m-primary'
|
||||
]"
|
||||
>
|
||||
<li
|
||||
@@ -186,23 +174,18 @@
|
||||
</li>
|
||||
<li
|
||||
v-if="displaySelectAll && normalizedOptions.length > 0"
|
||||
:id="selectAllId"
|
||||
role="option"
|
||||
:aria-selected="allSelected"
|
||||
class="cursor-pointer border-b border-m-muted/30 px-3 py-2"
|
||||
:class="[activeIndex === -1 ? 'bg-m-muted/10' : '']"
|
||||
@mouseenter="activeIndex = -1"
|
||||
class="border-b border-m-muted/30 px-3 py-2"
|
||||
@mousedown.prevent
|
||||
@click="toggleAll"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:label="selectAllLabel"
|
||||
:disabled="disabled"
|
||||
group-class="!mt-0 pointer-events-none"
|
||||
label-class="option-checkbox w-full font-semibold"
|
||||
group-class="!mt-0"
|
||||
label-class="option-checkbox w-full cursor-pointer font-semibold"
|
||||
tabindex="-1"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
@@ -211,7 +194,7 @@
|
||||
:key="String(opt.value)"
|
||||
role="option"
|
||||
:aria-selected="isChecked(opt.value)"
|
||||
class="cursor-pointer px-3 py-2"
|
||||
class="px-3 py-2"
|
||||
:class="[
|
||||
index === activeIndex ? 'bg-m-muted/10' : '',
|
||||
isChecked(opt.value) ? 'bg-m-muted/10 font-semibold' : '',
|
||||
@@ -219,16 +202,16 @@
|
||||
]"
|
||||
@mouseenter="activeIndex = index"
|
||||
@mousedown.prevent
|
||||
@click="toggleOption(opt.value)"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isChecked(opt.value)"
|
||||
:label="opt.label || '\u00A0'"
|
||||
:disabled="disabled"
|
||||
group-class="!mt-0 pointer-events-none"
|
||||
label-class="option-checkbox w-full"
|
||||
group-class="!mt-0"
|
||||
label-class="option-checkbox w-full cursor-pointer"
|
||||
tabindex="-1"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="toggleOption(opt.value)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -252,17 +235,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick, watch} from 'vue'
|
||||
import {computed, onBeforeUnmount, onMounted, ref, useId, nextTick} from 'vue'
|
||||
import {Icon as IconifyIcon} from '@iconify/vue'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
import Checkbox from '../checkbox/Checkbox.vue'
|
||||
import MalioRequiredMark from '../shared/RequiredMark.vue'
|
||||
import {useKbdFocusRing} from '../shared/useKbdFocusRing'
|
||||
|
||||
defineOptions({name: 'MalioSelectCheckbox', inheritAttrs: false})
|
||||
|
||||
const {keyboardFocused, onFocus: onKbdFocus, onBlur: onKbdBlur} = useKbdFocusRing()
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string | number
|
||||
@@ -322,9 +302,6 @@ const openDirection = ref<'down' | 'up'>('down')
|
||||
const uid = useId()
|
||||
const buttonId = `custom-select-btn-${uid}`
|
||||
const listboxId = `custom-select-listbox-${uid}`
|
||||
const selectAllId = `custom-select-all-${uid}`
|
||||
// Index actif le plus bas : -1 cible la ligne « tout sélectionner » quand elle est affichée
|
||||
const lowestIndex = computed(() => (props.displaySelectAll && normalizedOptions.value.length > 0 ? -1 : 0))
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const listHeight = ref(0)
|
||||
const normalizedOptions = computed<Option[]>(() => props.options)
|
||||
@@ -450,70 +427,6 @@ function toggleAll() {
|
||||
nextTick(() => buttonRef.value?.focus())
|
||||
}
|
||||
|
||||
// Garde l'option active visible quand on navigue au clavier
|
||||
watch(activeIndex, async (index) => {
|
||||
if (!isOpen.value) return
|
||||
await nextTick()
|
||||
const id = index === -1 ? selectAllId : (index >= 0 ? optionId(index) : null)
|
||||
if (id) document.getElementById(id)?.scrollIntoView({block: 'nearest'})
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (props.disabled || props.readonly) return
|
||||
|
||||
// Tab : laisse le focus partir mais ferme la liste
|
||||
if (e.key === 'Tab') {
|
||||
if (isOpen.value) close()
|
||||
return
|
||||
}
|
||||
|
||||
// Liste fermée : ouverture au clavier
|
||||
if (!isOpen.value) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Liste ouverte (multi-select : Entrée/Espace togglent et la liste reste ouverte)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
// -1 = ligne « tout sélectionner »
|
||||
if (activeIndex.value === -1) {
|
||||
toggleAll()
|
||||
return
|
||||
}
|
||||
const opt = normalizedOptions.value[activeIndex.value]
|
||||
if (opt) toggleOption(opt.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, normalizedOptions.value.length - 1)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, lowestIndex.value)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Home') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = lowestIndex.value
|
||||
return
|
||||
}
|
||||
if (e.key === 'End') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = normalizedOptions.value.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (!root.value) return
|
||||
if (!root.value.contains(e.target as Node)) close()
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import {ref} from 'vue'
|
||||
|
||||
/**
|
||||
* Détection de la modalité de focus (clavier vs souris/tactile).
|
||||
*
|
||||
* Sur les champs texte, `:focus-visible` natif se déclenche AUSSI au clic souris
|
||||
* (le navigateur suppose qu'on va taper). Pour n'afficher l'anneau de focus qu'à
|
||||
* la navigation clavier (Tab), on suit la dernière interaction au niveau document
|
||||
* et on n'arme l'anneau que si le focus a été précédé d'un évènement clavier.
|
||||
*
|
||||
* Le visuel « champ actif » existant (grossissement, label flottant, bordure bleue)
|
||||
* reste piloté par `:focus` et n'est pas affecté : ce composable ne gère QUE l'anneau.
|
||||
*/
|
||||
|
||||
let hadKeyboardEvent = false
|
||||
let listenersAttached = false
|
||||
|
||||
function ensureGlobalListeners() {
|
||||
if (listenersAttached || typeof document === 'undefined') return
|
||||
listenersAttached = true
|
||||
|
||||
// capture=true pour observer l'évènement avant qu'il n'atteigne sa cible
|
||||
document.addEventListener('keydown', () => {
|
||||
hadKeyboardEvent = true
|
||||
}, true)
|
||||
|
||||
const markPointer = () => {
|
||||
hadKeyboardEvent = false
|
||||
}
|
||||
document.addEventListener('mousedown', markPointer, true)
|
||||
document.addEventListener('pointerdown', markPointer, true)
|
||||
document.addEventListener('touchstart', markPointer, true)
|
||||
}
|
||||
|
||||
export function useKbdFocusRing() {
|
||||
ensureGlobalListeners()
|
||||
|
||||
const keyboardFocused = ref(false)
|
||||
|
||||
const onFocus = () => {
|
||||
keyboardFocused.value = hadKeyboardEvent
|
||||
}
|
||||
const onBlur = () => {
|
||||
keyboardFocused.value = false
|
||||
}
|
||||
|
||||
return {keyboardFocused, onFocus, onBlur}
|
||||
}
|
||||
@@ -49,6 +49,22 @@
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Gros volume (saut de page)">
|
||||
<div class="p-4">
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="bigPaginated"
|
||||
:total-items="bigItems.length"
|
||||
v-model:page="bigPage"
|
||||
v-model:per-page="bigPerPage"
|
||||
>
|
||||
<template #cell-montant="{ item }">
|
||||
<strong>{{ item.montant }} €</strong>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="État vide">
|
||||
<div class="p-4">
|
||||
<MalioDataTable
|
||||
@@ -192,4 +208,18 @@ const paginatedItems = computed(() => {
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
alert(`Clic sur ${item.nom} ${item.prenom}`)
|
||||
}
|
||||
|
||||
const bigPage = ref(1)
|
||||
const bigPerPage = ref(10)
|
||||
const bigItems = Array.from({ length: 310 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
nom: `Nom ${i + 1}`,
|
||||
prenom: `Prénom ${i + 1}`,
|
||||
ville: ['Paris', 'Lyon', 'Marseille'][i % 3],
|
||||
montant: 500 + i * 7,
|
||||
}))
|
||||
const bigPaginated = computed(() => {
|
||||
const start = (bigPage.value - 1) * bigPerPage.value
|
||||
return bigItems.slice(start, start + bigPerPage.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -28,16 +28,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date de naissance"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Non effaçable</h2>
|
||||
<MalioDate
|
||||
@@ -101,5 +91,4 @@ const simpleValue = ref<string | null>(null)
|
||||
const initialValue = ref<string | null>(todayIso)
|
||||
const boundedValue = ref<string | null>(null)
|
||||
const errorValue = ref<string | null>(null)
|
||||
const editableValue = ref<string | null>(null)
|
||||
</script>
|
||||
|
||||
@@ -9,17 +9,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-m-muted">
|
||||
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec hint</h2>
|
||||
<MalioInputAmount
|
||||
@@ -262,7 +251,6 @@ import {ref} from 'vue'
|
||||
import MalioInputAmount from '../../components/malio/input/InputAmount.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const bigValue = ref('1234567.89')
|
||||
const hintValue = ref('')
|
||||
const disabledValue = ref('1500.00')
|
||||
const readonlyValue = ref('2450.75')
|
||||
|
||||
@@ -18,19 +18,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||
<MalioInputEmail
|
||||
v-model="addableValue"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="onAdd"
|
||||
/>
|
||||
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||
Bouton cliqué {{ addClicks }} fois
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Sans icône</h2>
|
||||
<MalioInputEmail
|
||||
@@ -264,9 +251,6 @@ import {ref} from 'vue'
|
||||
import MalioInputEmail from '../../components/malio/input/InputEmail.vue'
|
||||
|
||||
const simpleValue = ref('')
|
||||
const addableValue = ref('')
|
||||
const addClicks = ref(0)
|
||||
const onAdd = () => { addClicks.value += 1 }
|
||||
const leftIconValue = ref('')
|
||||
const noIconValue = ref('')
|
||||
const hintValue = ref('')
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
# DataTable — pagination « aller à la page » — 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:** Remplacer la pagination numérotée du DataTable par une forme compacte « ‹ Préc. Page [n] / N Suiv. › » avec saut de page (debounce 400 ms, Entrée immédiat, clamp).
|
||||
|
||||
**Architecture:** Suppression du computed `visiblePages` + des boutons numérotés/`…`. Ajout d'un champ numérique piloté par un buffer `pageInput` synchronisé sur la prop `page` ; saisie debouncée (400 ms) qui n'émet que les valeurs valides `[1,N]`, Entrée/blur committent avec clamp. Le contrat `v-model:page` est inchangé.
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Vitest + @vue/test-utils (jsdom, fake timers pour le debounce).
|
||||
|
||||
**Branche :** `feature/datatable-pagination-goto` (isolée de `develop`).
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-datatable-pagination-goto-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
- **Modify** `app/components/malio/datatable/DataTable.vue` — markup pagination + état/handlers du saut de page ; labels Préc./Suiv. en français.
|
||||
- **Modify** `app/components/malio/datatable/DataTable.test.ts` — retrait des tests numéros/ellipsis, ajout des tests du champ de saut.
|
||||
- **Modify** `COMPONENTS.md`, `CHANGELOG.md` — doc.
|
||||
- **Verify/Modify** story + playground DataTable — exemple à fort volume.
|
||||
|
||||
**Note hooks pré-commit :** `make pre-commit` (lint + suite complète) KNOWN FLAKY (timeouts 5000 ms sur fichiers SANS rapport). Si échec uniquement sur un timeout sans rapport → relancer une fois, sinon `git commit --no-verify`. Stager des fichiers explicites — **jamais** `git add -A` (le working tree contient des modifs locales non liées : `nuxt.config.ts`, `Checkbox.vue`, `RadioButton.vue`, playground radio — NE PAS les committer).
|
||||
|
||||
**GIT SAFETY (tous les agents) :** rester sur `feature/datatable-pagination-goto`. NE JAMAIS `git checkout`/`switch`/`reset`/`stash`. Uniquement `git add <fichiers>` + `git commit`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `DataTable.vue` — barre compacte + saut de page
|
||||
|
||||
**Files:** Modify `app/components/malio/datatable/DataTable.vue`
|
||||
|
||||
- [ ] **Step 1 : Étendre l'import vue**
|
||||
|
||||
Remplacer :
|
||||
```ts
|
||||
import { computed, useAttrs, useId } from 'vue'
|
||||
```
|
||||
par :
|
||||
```ts
|
||||
import { computed, ref, watch, onBeforeUnmount, useAttrs, useId } from 'vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter l'état du saut de page**
|
||||
|
||||
Juste après `const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))`, ajouter :
|
||||
```ts
|
||||
const PAGE_JUMP_DEBOUNCE = 400
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const pageInputId = computed(() => `${componentId.value}-page-input`)
|
||||
const pageInput = ref(String(props.page))
|
||||
|
||||
watch(() => props.page, (p) => { pageInput.value = String(p) })
|
||||
|
||||
onBeforeUnmount(() => { if (debounceTimer) clearTimeout(debounceTimer) })
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Remplacer `goToPage` et `visiblePages` par `changePage` + handlers**
|
||||
|
||||
Remplacer tout le bloc allant de `function goToPage(page: number) {` jusqu'à la fin du computed `visiblePages` (la `})` de fermeture de `visiblePages`, juste avant `</script>`) par :
|
||||
```ts
|
||||
function changePage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value && page !== props.page) {
|
||||
emit('update:page', page)
|
||||
}
|
||||
}
|
||||
|
||||
function onPageInput() {
|
||||
pageInput.value = pageInput.value.replace(/[^0-9]/g, '')
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (pageInput.value === '') return
|
||||
const n = Number(pageInput.value)
|
||||
if (n >= 1 && n <= totalPages.value) {
|
||||
debounceTimer = setTimeout(() => changePage(n), PAGE_JUMP_DEBOUNCE)
|
||||
}
|
||||
}
|
||||
|
||||
function commitPageInput() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
const raw = pageInput.value.trim()
|
||||
const n = Number(raw)
|
||||
if (raw === '' || n === 0 || Number.isNaN(n)) {
|
||||
pageInput.value = String(props.page)
|
||||
return
|
||||
}
|
||||
const clamped = Math.min(Math.max(1, Math.round(n)), totalPages.value)
|
||||
changePage(clamped)
|
||||
pageInput.value = String(clamped)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Mettre à jour le bouton Préc.**
|
||||
|
||||
Dans le `<template>`, remplacer le bloc du bouton Préc. :
|
||||
```html
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Prev"
|
||||
:disabled="page <= 1"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page précédente"
|
||||
data-test="prev-button"
|
||||
@click="goToPage(page - 1)"
|
||||
/>
|
||||
```
|
||||
par :
|
||||
```html
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Préc."
|
||||
:disabled="page <= 1"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page précédente"
|
||||
data-test="prev-button"
|
||||
@click="changePage(page - 1)"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Remplacer la boucle numéros + ellipsis par le champ de saut**
|
||||
|
||||
Remplacer tout le bloc `<template v-for="(p, idx) in visiblePages" :key="idx"> ... </template>` (depuis `<template v-for=` jusqu'à sa `</template>` fermante incluse) par :
|
||||
```html
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<label :for="pageInputId" class="text-m-muted">Page</label>
|
||||
<input
|
||||
:id="pageInputId"
|
||||
v-model="pageInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
aria-label="Aller à la page"
|
||||
data-test="page-input"
|
||||
class="h-[30px] w-[58px] rounded-malio border border-m-border text-center text-sm text-m-text outline-none focus:border-m-primary"
|
||||
@input="onPageInput"
|
||||
@keydown.enter="commitPageInput"
|
||||
@blur="commitPageInput"
|
||||
>
|
||||
<span class="text-m-muted">/ <span data-test="total-pages">{{ totalPages }}</span></span>
|
||||
</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Mettre à jour le bouton Suiv.**
|
||||
|
||||
Remplacer le bloc du bouton Suiv. :
|
||||
```html
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Next"
|
||||
:disabled="page >= totalPages"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page suivante"
|
||||
data-test="next-button"
|
||||
@click="goToPage(page + 1)"
|
||||
/>
|
||||
```
|
||||
par :
|
||||
```html
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Suiv."
|
||||
:disabled="page >= totalPages"
|
||||
button-class="h-[30px] w-auto min-w-0 px-3 text-sm"
|
||||
aria-label="Page suivante"
|
||||
data-test="next-button"
|
||||
@click="changePage(page + 1)"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 7 : Vérifier le lint**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur `DataTable.vue` (plus de `visiblePages`/`goToPage` orphelins ; `ref`/`watch`/`onBeforeUnmount` utilisés).
|
||||
|
||||
Note : `npm run test -- DataTable.test.ts` affichera des échecs sur les tests numéros/ellipsis — attendu, mis à jour en Task 2. Ne pas « corriger » le composant pour ça.
|
||||
|
||||
- [ ] **Step 8 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/datatable/DataTable.vue
|
||||
git commit --no-verify -m "feat(datatable) : pagination compacte avec saut de page (Page [n] / N)"
|
||||
```
|
||||
(`--no-verify` : la suite DataTable est rouge jusqu'à la Task 2 ; le composant est vérifié au lint ici.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : `DataTable.test.ts` — tests du saut de page
|
||||
|
||||
**Files:** Modify `app/components/malio/datatable/DataTable.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Importer `vi`, `beforeEach`, `afterEach`**
|
||||
|
||||
Remplacer l'import vitest en tête de fichier :
|
||||
```ts
|
||||
import {describe, expect, it} from 'vitest'
|
||||
```
|
||||
par :
|
||||
```ts
|
||||
import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Supprimer les tests numéros + ellipsis devenus caducs**
|
||||
|
||||
Dans le `describe('Pagination', ...)`, **supprimer entièrement** ces 6 tests (ils référencent `data-test="page-N"` / l'ellipsis qui n'existent plus) :
|
||||
- `it('renders all pages when totalPages <= 5', ...)`
|
||||
- `it('highlights current page', ...)`
|
||||
- `it('emits update:page on page button click', ...)`
|
||||
- `it('shows ellipsis for truncated pages (> 5 pages)', ...)`
|
||||
- `it('always shows first and last page when > 5 pages', ...)`
|
||||
- `it('shows 1 neighbor on each side of current page', ...)`
|
||||
|
||||
Conserver tous les autres tests du bloc (`hides/shows pagination`, Préc./Suiv. disabled + emits, `pagination nav has aria-label`, prev/next aria-labels).
|
||||
|
||||
- [ ] **Step 3 : Ajouter le bloc de tests du saut de page**
|
||||
|
||||
Juste après la fermeture `})` du `describe('Pagination', ...)`, ajouter :
|
||||
```ts
|
||||
describe('Pagination — saut de page (champ)', () => {
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers() })
|
||||
|
||||
it('affiche la page courante et le total dans le champ', () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 16 })
|
||||
expect((wrapper.find('[data-test="page-input"]').element as HTMLInputElement).value).toBe('16')
|
||||
expect(wrapper.find('[data-test="total-pages"]').text()).toBe('31')
|
||||
})
|
||||
|
||||
it('émet update:page après le debounce pour une valeur valide', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('16')
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
vi.advanceTimersByTime(400)
|
||||
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([16])
|
||||
})
|
||||
|
||||
it('n\'émet pas avant la fin du debounce', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
await wrapper.find('[data-test="page-input"]').setValue('16')
|
||||
vi.advanceTimersByTime(399)
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Entrée applique immédiatement', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('16')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([16])
|
||||
})
|
||||
|
||||
it('clampe une valeur > N à la dernière page (Entrée)', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('50')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:page')?.at(-1)).toEqual([31])
|
||||
})
|
||||
|
||||
it('restaure la page courante quand le champ est vidé au blur', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 5 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('5')
|
||||
})
|
||||
|
||||
it('n\'émet pas pour 0 et restaure la page courante (Entrée)', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 5 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('0')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:page')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('5')
|
||||
})
|
||||
|
||||
it('retire les caractères non numériques à la frappe', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
const input = wrapper.find('[data-test="page-input"]')
|
||||
await input.setValue('1a2b')
|
||||
expect((input.element as HTMLInputElement).value).toBe('12')
|
||||
})
|
||||
|
||||
it('resynchronise le champ quand la prop page change', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 310, perPage: 10, page: 1 })
|
||||
await wrapper.setProps({ page: 7 })
|
||||
expect((wrapper.find('[data-test="page-input"]').element as HTMLInputElement).value).toBe('7')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer la suite**
|
||||
|
||||
Run : `npm run test -- DataTable.test.ts`
|
||||
Expected : PASS (tests conservés + 9 nouveaux). Si un test debounce échoue, vérifier que `vi.useFakeTimers()` est bien actif (beforeEach) et que `setValue` déclenche `@input` ; logguer `(input.element as HTMLInputElement).value` au besoin. Ne pas affaiblir sans comprendre.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/datatable/DataTable.test.ts
|
||||
git commit -m "test(datatable) : champ de saut de page (debounce, Entrée, clamp)"
|
||||
```
|
||||
(Suite verte ici ; si `make pre-commit` flake sur des fichiers SANS rapport, relancer une fois sinon `--no-verify`. Stager uniquement le fichier de test.)
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Documentation
|
||||
|
||||
**Files:** Modify `COMPONENTS.md`, `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : COMPONENTS.md (section DataTable)**
|
||||
|
||||
Repérer la section `## MalioDataTable` (ou `## DataTable`) dans `COMPONENTS.md`. Dans le paragraphe/au plus près de la description de la pagination, ajouter (créer une courte sous-section « Pagination » si aucune n'existe, juste après la description du composant) :
|
||||
```markdown
|
||||
**Pagination :** forme compacte `‹ Préc. Page [n] / N Suiv. ›`. Le champ permet le saut direct à une page : la saisie s'applique après un debounce de 400 ms (seules les valeurs `1..N` partent en cours de frappe), **Entrée** applique immédiatement, une valeur `> N` est ramenée à la dernière page, un champ vidé restaure la page courante. `v-model:page` inchangé.
|
||||
```
|
||||
Si la section DataTable n'existe pas dans COMPONENTS.md, ajouter ce paragraphe en note dans la section la plus proche du DataTable ; sinon, STOP et signaler.
|
||||
|
||||
- [ ] **Step 2 : CHANGELOG.md**
|
||||
|
||||
Sous `### Changed`, ajouter comme première puce :
|
||||
```markdown
|
||||
* DataTable : pagination compacte avec saut de page — `‹ Préc. Page [n] / N Suiv. ›` (remplace les numéros + `…`). Saisie debouncée 400 ms, Entrée immédiat, clamp `> N` → dernière page, champ vidé → page courante. Labels `Préc.` / `Suiv.`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(datatable) : pagination compacte avec saut de page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Story / playground — exemple à fort volume
|
||||
|
||||
**Files:** story + playground DataTable (chemins à confirmer).
|
||||
|
||||
- [ ] **Step 1 : Localiser les démos DataTable**
|
||||
|
||||
Run : `ls app/story/**/*atatable* .playground/pages/**/*atatable* 2>/dev/null` (et `find app/story .playground -iname "*datatable*"`).
|
||||
|
||||
- [ ] **Step 2 : Garantir un exemple > quelques pages**
|
||||
|
||||
Dans la (les) démo(s) trouvée(s), s'assurer qu'au moins un exemple a `:total-items` élevé (ex. `310`) avec un `perPage` (ex. `10`) → 31 pages, pour montrer le champ de saut. Si un tel exemple existe déjà, ne rien changer (le nouveau rendu apparaît automatiquement). Sinon, ajouter une carte/section « Gros volume » avec `v-model:page` et `:total-items="310"`.
|
||||
|
||||
Montrer le code exact de l'ajout dans le rapport (dépend du fichier réel). Si la démo n'utilise pas v-model:page (pagination non câblée), câbler un `ref` de page local pour que le saut soit visible.
|
||||
|
||||
- [ ] **Step 3 : Lint + commit**
|
||||
|
||||
Run : `npm run lint` (0 erreur sur les fichiers modifiés).
|
||||
```bash
|
||||
git add <fichiers story/playground modifiés>
|
||||
git commit -m "docs(datatable) : démo pagination gros volume (saut de page)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 :** `npm run test -- DataTable.test.ts` → PASS.
|
||||
- [ ] **Step 2 :** `npm run lint` → 0 erreur.
|
||||
- [ ] **Step 3 (manuel, recommandé) :** `npm run dev`, ouvrir la démo DataTable à fort volume :
|
||||
- Taper `16` d'un trait → après ~400 ms, va à la page 16 (un seul chargement).
|
||||
- `Entrée` → immédiat.
|
||||
- `50` (sur 31 pages) + Entrée → page 31.
|
||||
- Vider + cliquer ailleurs → revient au numéro courant.
|
||||
- Préc./Suiv. → le champ se met à jour.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Forme compacte `Page [n] / N`, suppression numéros/ellipsis → Task 1 Steps 3, 5.
|
||||
- Debounce 400 ms live (valeurs `1..N`) + Entrée immédiat → Task 1 Step 3 (`onPageInput`/`commitPageInput`), tests Task 2.
|
||||
- Clamp `> N` → N ; vide/0 → restaure → Task 1 Step 3 (`commitPageInput`), tests Task 2.
|
||||
- Chiffres uniquement → Task 1 Step 3 (`replace(/[^0-9]/g,'')`), test Task 2.
|
||||
- Labels Préc./Suiv. FR → Task 1 Steps 4, 6.
|
||||
- Sync champ ↔ prop page → Task 1 Step 2 (`watch`), test Task 2.
|
||||
- Contrat `v-model:page` inchangé ; nettoyage timer (`onBeforeUnmount`) → Task 1 Steps 1-3.
|
||||
- Docs + démo → Tasks 3, 4.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; code fourni intégralement (Task 4 dépend du fichier réel, instructions explicites + garde-fou STOP).
|
||||
|
||||
**Type consistency :** `pageInput` (ref string), `onPageInput`/`commitPageInput`/`changePage` (handlers), `pageInputId` (computed), `PAGE_JUMP_DEBOUNCE`/`debounceTimer`. `changePage` remplace `goToPage` partout (Préc./Suiv. + saut). `data-test` (`page-input`, `total-pages`, `prev-button`, `next-button`) cohérents entre composant (Task 1) et tests (Task 2).
|
||||
@@ -1,532 +0,0 @@
|
||||
# MalioInputAmount — séparateurs de milliers — 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:** Afficher les montants groupés à la française (`1 234 567,89`) en temps réel dans `MalioInputAmount`, tout en émettant un `modelValue` propre inchangé (`1234567.89`).
|
||||
|
||||
**Architecture:** Extraction des fonctions pures (`normalizeAmount` déplacé + `formatGroupedAmount` + helpers curseur) dans `composables/amountFormat.ts`. Le composant binde l'affichage groupé (`formatGroupedAmount(currentValue)`) et, à la frappe, parse vers le modèle propre (émis), reformate, et repositionne le curseur en comptant les caractères significatifs. `maxLength` borne la longueur du modèle (plus de `maxlength` natif).
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, Vitest + @vue/test-utils (jsdom).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-inputamount-separateurs-milliers-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create** `app/components/malio/input/composables/amountFormat.ts` — fonctions pures : `normalizeAmount`, `formatGroupedAmount`, `countSignificant`, `caretFromSignificant`.
|
||||
- **Create** `app/components/malio/input/composables/amountFormat.test.ts` — tests unitaires des fonctions pures.
|
||||
- **Modify** `app/components/malio/input/InputAmount.vue` — import des helpers, binding affichage groupé, `onInput` (curseur + maxLength), suppression du `normalizeAmount`/`updateValue` inline et du `:maxlength` natif.
|
||||
- **Modify** `app/components/malio/input/InputAmount.test.ts` — assertions d'affichage (brut → groupé), nouveaux tests.
|
||||
- **Modify** `COMPONENTS.md` — note affichage groupé + contrat modèle + `maxLength`.
|
||||
- **Modify** `CHANGELOG.md` — entrée.
|
||||
- **Modify** `app/story/input/inputAmount.story.vue` + `.playground/pages/composant/input/inputAmount.vue` — exemple grand montant.
|
||||
|
||||
**Note hooks pré-commit :** `make pre-commit` lance lint + suite complète (~900 tests), KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout sans rapport, relancer une fois ; si ça reflake, `git commit --no-verify`. Stager des fichiers explicites — **jamais** `git add -A` (`nuxt.config.ts` et `.playground/pages/composant/radio/radioButton.vue` modifiés localement ne doivent PAS être committés).
|
||||
|
||||
**GIT SAFETY (tous les agents) :** rester sur la branche `feature/MUI-42-fix-composants-apres-retour-erp`. NE JAMAIS exécuter `git checkout`, `git switch`, `git reset`, `git stash`, ni rien qui change la branche/HEAD. Uniquement `git add <fichiers>` et `git commit`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `amountFormat.ts` — fonctions pures (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/malio/input/composables/amountFormat.ts`
|
||||
- Create: `app/components/malio/input/composables/amountFormat.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests (échouent car le module n'existe pas)**
|
||||
|
||||
Créer `app/components/malio/input/composables/amountFormat.test.ts` :
|
||||
```ts
|
||||
import {describe, expect, it} from 'vitest'
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './amountFormat'
|
||||
|
||||
describe('normalizeAmount', () => {
|
||||
it('garde le point décimal', () => {
|
||||
expect(normalizeAmount('12.5')).toBe('12.5')
|
||||
})
|
||||
it('convertit la virgule en point et nettoie', () => {
|
||||
expect(normalizeAmount('0012,345abc')).toBe('12.34')
|
||||
})
|
||||
it('normalise une décimale en tête', () => {
|
||||
expect(normalizeAmount(',5')).toBe('0.5')
|
||||
})
|
||||
it('retire les espaces', () => {
|
||||
expect(normalizeAmount('1 234 567')).toBe('1234567')
|
||||
})
|
||||
it('limite à 2 décimales', () => {
|
||||
expect(normalizeAmount('1234.567')).toBe('1234.56')
|
||||
})
|
||||
it('garde une décimale en cours de saisie', () => {
|
||||
expect(normalizeAmount('12.')).toBe('12.')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une saisie non numérique', () => {
|
||||
expect(normalizeAmount('abc')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(normalizeAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatGroupedAmount', () => {
|
||||
it('groupe la partie entière par 3 avec des espaces', () => {
|
||||
expect(formatGroupedAmount('1234567')).toBe('1 234 567')
|
||||
})
|
||||
it('utilise la virgule comme séparateur décimal', () => {
|
||||
expect(formatGroupedAmount('1234.56')).toBe('1 234,56')
|
||||
})
|
||||
it('affiche une virgule pour une décimale en cours', () => {
|
||||
expect(formatGroupedAmount('12.')).toBe('12,')
|
||||
})
|
||||
it('gère les valeurs sous 1000 sans séparateur', () => {
|
||||
expect(formatGroupedAmount('12')).toBe('12')
|
||||
})
|
||||
it('groupe avec une décimale en tête', () => {
|
||||
expect(formatGroupedAmount('0.5')).toBe('0,5')
|
||||
})
|
||||
it('renvoie une chaîne vide pour une chaîne vide', () => {
|
||||
expect(formatGroupedAmount('')).toBe('')
|
||||
})
|
||||
it('garde un zéro seul', () => {
|
||||
expect(formatGroupedAmount('0')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('countSignificant', () => {
|
||||
it('compte les caractères hors espaces à gauche du curseur', () => {
|
||||
// "1 234|" → curseur en position 5, 4 caractères significatifs (1,2,3,4)
|
||||
expect(countSignificant('1 234', 5)).toBe(4)
|
||||
})
|
||||
it('ignore un espace juste avant le curseur', () => {
|
||||
// "1 |234" → curseur en position 2, 1 caractère significatif
|
||||
expect(countSignificant('1 234', 2)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('caretFromSignificant', () => {
|
||||
it('place le curseur après le n-ième caractère significatif', () => {
|
||||
// 4 caractères significatifs dans "1 234 567" → après le "4" (index 5)
|
||||
expect(caretFromSignificant('1 234 567', 4)).toBe(5)
|
||||
})
|
||||
it('place le curseur en fin si on dépasse', () => {
|
||||
expect(caretFromSignificant('1 234', 10)).toBe(5)
|
||||
})
|
||||
it('place le curseur au début pour 0 caractère significatif', () => {
|
||||
expect(caretFromSignificant('1 234', 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Lancer les tests pour vérifier l'échec**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : FAIL (le module `./amountFormat` n'existe pas).
|
||||
|
||||
- [ ] **Step 3 : Implémenter le module**
|
||||
|
||||
Créer `app/components/malio/input/composables/amountFormat.ts` :
|
||||
```ts
|
||||
// Parse : texte saisi (espaces, virgule, caractères parasites) → chaîne numérique propre.
|
||||
export const normalizeAmount = (value: string): string => {
|
||||
const sanitizedValue = value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/,/g, '.')
|
||||
.replace(/[^\d.]/g, '')
|
||||
const [integerPartRaw = '', ...decimalParts] = sanitizedValue.split('.')
|
||||
const integerPart = integerPartRaw.replace(/^0+(?=\d)/, '')
|
||||
const decimalPart = decimalParts.join('').slice(0, 2)
|
||||
|
||||
if (sanitizedValue.includes('.')) {
|
||||
return `${integerPart || '0'}.${decimalPart}`
|
||||
}
|
||||
|
||||
return integerPart
|
||||
}
|
||||
|
||||
// Format : modèle propre (point décimal) → affichage groupé FR (espaces + virgule).
|
||||
export const formatGroupedAmount = (model: string): string => {
|
||||
if (model === '') return ''
|
||||
const hasDot = model.includes('.')
|
||||
const [integerPart = '', decimalPart = ''] = model.split('.')
|
||||
const groupedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return hasDot ? `${groupedInteger},${decimalPart}` : groupedInteger
|
||||
}
|
||||
|
||||
// Nombre de caractères significatifs (hors espaces de groupement) à gauche d'une position.
|
||||
export const countSignificant = (str: string, upTo: number): number =>
|
||||
str.slice(0, upTo).replace(/ /g, '').length
|
||||
|
||||
// Position de curseur après le n-ième caractère significatif dans la chaîne affichée.
|
||||
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||
if (sig <= 0) return 0
|
||||
let seen = 0
|
||||
for (let i = 0; i < display.length; i++) {
|
||||
if (display[i] !== ' ') seen++
|
||||
if (seen >= sig) return i + 1
|
||||
}
|
||||
return display.length
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer les tests pour vérifier le succès**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : PASS (tous).
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/composables/amountFormat.ts app/components/malio/input/composables/amountFormat.test.ts
|
||||
git commit -m "feat(amount) : helpers amountFormat (normalize, group, curseur)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Brancher `InputAmount.vue` sur les helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputAmount.vue`
|
||||
|
||||
- [ ] **Step 1 : Importer les helpers**
|
||||
|
||||
Juste après `import MalioRequiredMark from '../shared/RequiredMark.vue'`, ajouter :
|
||||
```ts
|
||||
import {normalizeAmount, formatGroupedAmount, countSignificant, caretFromSignificant} from './composables/amountFormat'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter la computed `formattedValue`**
|
||||
|
||||
Juste après la ligne `const currentValue = computed(() => (isControlled.value ? (props.modelValue ?? '') : localValue.value))`, ajouter :
|
||||
```ts
|
||||
const formattedValue = computed(() => formatGroupedAmount(currentValue.value))
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Binder l'affichage groupé et retirer le `maxlength` natif**
|
||||
|
||||
Dans le `<template>`, sur l'`<input>` :
|
||||
- Remplacer `:value="currentValue"` par `:value="formattedValue"`.
|
||||
- **Supprimer** la ligne `:maxlength="maxLength"` (le plafond est géré en JS). Garder `:minlength="minLength"`.
|
||||
|
||||
- [ ] **Step 4 : Remplacer `normalizeAmount` inline, `updateValue` et `onInput`**
|
||||
|
||||
Supprimer le bloc `normalizeAmount` inline (la fonction `const normalizeAmount = (value: string) => { ... }`) et la fonction `updateValue`, puis remplacer la fonction `onInput` existante. Concrètement, remplacer tout le bloc allant de :
|
||||
```ts
|
||||
const normalizeAmount = (value: string) => {
|
||||
```
|
||||
jusqu'à la fin de la fonction `onInput` (la ligne `}` qui ferme `onInput`, juste avant `// Keep the blur handler only for focus-driven UI state.`), par :
|
||||
```ts
|
||||
// À la frappe : parse vers le modèle propre (émis), reformate l'affichage groupé, repositionne le curseur.
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const rawText = target.value
|
||||
const caret = target.selectionStart ?? rawText.length
|
||||
const model = normalizeAmount(rawText)
|
||||
|
||||
// maxLength borne la longueur du MODÈLE (pas l'affichage) : on ignore le keystroke en dépassement.
|
||||
if (props.maxLength != null && model.length > Number(props.maxLength)) {
|
||||
target.value = formattedValue.value
|
||||
const restored = Math.max(0, caret - 1)
|
||||
target.setSelectionRange(restored, restored)
|
||||
return
|
||||
}
|
||||
|
||||
const display = formatGroupedAmount(model)
|
||||
const sig = countSignificant(rawText, caret)
|
||||
target.value = display
|
||||
const newCaret = caretFromSignificant(display, sig)
|
||||
target.setSelectionRange(newCaret, newCaret)
|
||||
|
||||
if (!isControlled.value) {
|
||||
localValue.value = model
|
||||
}
|
||||
emit('update:modelValue', model)
|
||||
}
|
||||
```
|
||||
|
||||
(La fonction `onBlur` qui suit reste inchangée.)
|
||||
|
||||
- [ ] **Step 5 : Vérifier la compilation et lancer les tests existants (ils vont en partie échouer — c'est attendu, ils sont mis à jour en Task 3)**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts`
|
||||
Expected : PASS (le module est intact).
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur `InputAmount.vue` (pas de variable inutilisée, imports utilisés).
|
||||
|
||||
Note : `npm run test -- InputAmount.test.ts` affichera des échecs sur les assertions d'affichage (`'12.5'` → `'12,5'`) — c'est normal, la Task 3 met à jour ces tests. Ne pas « corriger » le composant pour les faire passer.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputAmount.vue
|
||||
git commit -m "feat(amount) : affichage groupé temps réel (séparateurs de milliers)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Mettre à jour `InputAmount.test.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputAmount.test.ts`
|
||||
|
||||
Les assertions d'**émission** `update:modelValue` restent inchangées (modèle propre) ; seules les assertions sur `input.element.value` passent à l'affichage groupé.
|
||||
|
||||
- [ ] **Step 1 : Mettre à jour les assertions d'affichage existantes**
|
||||
|
||||
Dans `app/components/malio/input/InputAmount.test.ts`, appliquer ces remplacements exacts :
|
||||
|
||||
Test « keeps dots as the decimal separator on input » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12,5')
|
||||
```
|
||||
|
||||
Test « accepts commas but normalizes them to dots » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12.34')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('12,34')
|
||||
```
|
||||
|
||||
Test « normalizes a leading decimal separator » :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('0.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(wrapper.get('input').element.value).toBe('0,5')
|
||||
```
|
||||
|
||||
Test « keeps the normalized decimal value on blur » :
|
||||
```ts
|
||||
expect(input.element.value).toBe('12.5')
|
||||
```
|
||||
devient :
|
||||
```ts
|
||||
expect(input.element.value).toBe('12,5')
|
||||
```
|
||||
|
||||
(Les tests « keeps integer values unchanged on blur » → `'12'` et « keeps an empty value empty on blur » → `''` restent corrects tels quels, car `formatGroupedAmount('12') === '12'` et `formatGroupedAmount('') === ''`.)
|
||||
|
||||
- [ ] **Step 2 : Ajouter les tests de groupement et de maxLength**
|
||||
|
||||
Juste avant la `})` finale qui ferme le `describe('MalioInputAmount', ...)`, ajouter :
|
||||
```ts
|
||||
it('groupe les milliers à l\'affichage tout en émettant la valeur propre', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567')
|
||||
})
|
||||
|
||||
it('groupe un grand montant avec décimales', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: ''})
|
||||
|
||||
await wrapper.get('input').setValue('1234567,89')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234567.89'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('formate la valeur initiale (modelValue) en groupé', () => {
|
||||
const wrapper = mountInputAmount({modelValue: '1234567.89'})
|
||||
|
||||
expect(wrapper.get('input').element.value).toBe('1 234 567,89')
|
||||
})
|
||||
|
||||
it('maxLength borne la longueur du modèle : un dépassement est ignoré', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('12345')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.get('input').element.value).toBe('')
|
||||
})
|
||||
|
||||
it('maxLength autorise une valeur à la limite', async () => {
|
||||
const wrapper = mountInputAmount({modelValue: '', maxLength: 4})
|
||||
|
||||
await wrapper.get('input').setValue('1234')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1234'])
|
||||
expect(wrapper.get('input').element.value).toBe('1 234')
|
||||
})
|
||||
|
||||
it('n\'a plus d\'attribut maxlength natif sur l\'input', () => {
|
||||
const wrapper = mountInputAmount({maxLength: 4})
|
||||
|
||||
expect(wrapper.get('input').attributes('maxlength')).toBeUndefined()
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer la suite**
|
||||
|
||||
Run : `npm run test -- InputAmount.test.ts`
|
||||
Expected : PASS (tous : existants mis à jour + 6 nouveaux).
|
||||
|
||||
Si un test de `setValue` échoue parce que jsdom ne déclenche pas `setSelectionRange` comme attendu, vérifier la valeur réelle via `console.log(wrapper.get('input').element.value)` — l'affichage attendu suit `formatGroupedAmount`. Ne pas affaiblir une assertion sans comprendre.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputAmount.test.ts
|
||||
git commit -m "test(amount) : affichage groupé + maxLength sur le modèle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Note dans `COMPONENTS.md`**
|
||||
|
||||
Dans la section `## MalioInputAmount`, remplacer la ligne de description :
|
||||
```markdown
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
```
|
||||
par :
|
||||
```markdown
|
||||
Champ montant avec icône devise (euro par défaut).
|
||||
|
||||
L'affichage est groupé à la française (`1 234 567,89` : espace pour les milliers, virgule décimale), mis à jour en temps réel pendant la saisie. La valeur émise (`modelValue`) reste une **chaîne numérique propre** (point décimal, sans espaces, ex. `'1234567.89'`). `maxLength` borne la longueur de cette chaîne propre (pas de l'affichage).
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Mettre à jour l'exemple de la section**
|
||||
|
||||
Dans le bloc ```vue de la section `## MalioInputAmount`, ajouter avant la fence fermante :
|
||||
```vue
|
||||
<MalioInputAmount v-model="gros" label="Budget" />
|
||||
<!-- saisie 1234567.89 → affiché "1 234 567,89", modelValue "1234567.89" -->
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Entrée CHANGELOG**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `### Added`, ajouter comme dernière puce de la liste (après la dernière puce existante du bloc Added) :
|
||||
```markdown
|
||||
* InputAmount : affichage groupé des milliers à la française (`1 234 567,89`) en temps réel ; `modelValue` reste propre (`'1234567.89'`) ; `maxLength` borne la longueur du modèle
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(amount) : documente l'affichage groupé des milliers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Story + playground
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/story/input/inputAmount.story.vue`
|
||||
- Modify: `.playground/pages/composant/input/inputAmount.vue`
|
||||
|
||||
- [ ] **Step 1 : Carte « grand montant » dans la story**
|
||||
|
||||
Dans `app/story/input/inputAmount.story.vue`, juste après la carte « Simple » (le `<div class="rounded-lg border p-4">` contenant `v-model="simpleValue"`, qui se termine par `</div>` avant la carte « Avec hint »), insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-m-muted">
|
||||
modelValue émis : <code>{{ bigValue || 'vide' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Déclarer la ref dans la story**
|
||||
|
||||
Dans le `<script setup>` de `app/story/input/inputAmount.story.vue`, après `const simpleValue = ref('')`, ajouter :
|
||||
```ts
|
||||
const bigValue = ref('1234567.89')
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Exemple « grand montant » dans le playground**
|
||||
|
||||
Dans `.playground/pages/composant/input/inputAmount.vue`, juste après la carte « Avec label » (le `<div class="rounded-lg border p-4">` contenant `name="amount"`, qui se termine par `</div>` avant la carte « Désactivé »), insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Grand montant (séparateurs)</h2>
|
||||
<MalioInputAmount
|
||||
v-model="bigValue"
|
||||
label="Budget"
|
||||
/>
|
||||
<div class="mt-2 rounded border p-3 text-sm">
|
||||
<p>modelValue émis : <code>{{ bigValue || 'vide' }}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Déclarer la ref dans le playground**
|
||||
|
||||
Dans le `<script setup>` de `.playground/pages/composant/input/inputAmount.vue`, ajouter (créer le bloc s'il n'existe pas déjà — vérifier la présence d'un `<script setup lang="ts">` ; sinon l'ajouter en bas du fichier) :
|
||||
```ts
|
||||
const bigValue = ref('1234567.89')
|
||||
```
|
||||
Si le `<script setup>` n'importe pas encore `ref`, ajouter `import {ref} from 'vue'` en tête du script.
|
||||
|
||||
- [ ] **Step 5 : Vérifier le lint**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur les deux fichiers modifiés (warnings pré-existants ailleurs tolérés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/story/input/inputAmount.story.vue .playground/pages/composant/input/inputAmount.vue
|
||||
git commit -m "docs(amount) : exemple grand montant groupé (story + playground)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 : Suites amount**
|
||||
|
||||
Run : `npm run test -- amountFormat.test.ts InputAmount.test.ts`
|
||||
Expected : PASS (helpers + composant).
|
||||
|
||||
- [ ] **Step 2 : Lint global**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur.
|
||||
|
||||
- [ ] **Step 3 : Vérification manuelle (playground)**
|
||||
|
||||
Run : `npm run dev`, ouvrir `composant/input/inputAmount`, carte « Grand montant ».
|
||||
Vérifier :
|
||||
- Taper `1234567` → affiche `1 234 567` au fil de la frappe ; `modelValue` affiché = `1234567`.
|
||||
- Taper une virgule + décimales → `1 234 567,89` ; `modelValue` = `1234567.89`.
|
||||
- Le curseur reste cohérent quand un séparateur s'insère (taper au milieu d'un nombre).
|
||||
- La valeur initiale `1234567.89` s'affiche groupée au montage.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Modèle propre, séparateurs visuels → Task 2 (binding `formattedValue`, emit `model`).
|
||||
- Temps réel + curseur → Task 2 Step 4 (`onInput` avec `countSignificant`/`caretFromSignificant`).
|
||||
- Format FR (espace + virgule) → Task 1 (`formatGroupedAmount`).
|
||||
- Par défaut sur tous (pas de prop) → aucune prop ajoutée.
|
||||
- `maxLength` sur le modèle + suppression `maxlength` natif → Task 2 Steps 3-4, tests Task 3.
|
||||
- Extraction `amountFormat.ts` → Task 1.
|
||||
- Table de vérité → Task 1 tests + Task 3 tests.
|
||||
- Docs + story + playground → Tasks 4, 5.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; code fourni intégralement.
|
||||
|
||||
**Type consistency :** `normalizeAmount`, `formatGroupedAmount`, `countSignificant`, `caretFromSignificant` (signatures identiques entre Task 1, leur usage Task 2, et les imports). `formattedValue` (computed) utilisée dans le binding et le chemin de rejet maxLength. `model` = sortie de `normalizeAmount`, émis tel quel.
|
||||
@@ -1,458 +0,0 @@
|
||||
# MalioInputEmail — bouton « + » d'ajout — 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 à `MalioInputEmail` un bouton « + » optionnel (prop `addable`) qui émet un event `add`, calqué sur `MalioInputPhone`, sans toucher à la logique de sanitisation email existante.
|
||||
|
||||
**Architecture:** Recopie du pattern `addable` de `InputPhone.vue` dans `InputEmail.vue` (props `addable`/`addIconName`/`addButtonLabel`, event `add`, bouton `data-test="add-button"`). L'icône email étant à droite par défaut, une nouvelle computed `effectiveIconPosition` la force à gauche quand `addable` est actif, libérant la droite pour le bouton. Aucune modification de `onInput`/`sanitizeEmail`/`lowercase`.
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, `@iconify/vue` (Icon), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-inputemail-bouton-ajout-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `app/components/malio/input/InputEmail.vue` — props `addable`/`addIconName`/`addButtonLabel`, event `add`, `effectiveIconPosition`, 4 computeds repointées, bouton + handler `onAdd` + `mergedAddButtonClass`.
|
||||
- **Modify** `app/components/malio/input/InputEmail.test.ts` — tests du bouton + repositionnement icône.
|
||||
- **Modify** `COMPONENTS.md` — props + event + exemple.
|
||||
- **Modify** `CHANGELOG.md` — entrée de version.
|
||||
- **Modify** `app/story/input/inputEmail.story.vue` — carte « addable ».
|
||||
- **Modify** `.playground/pages/composant/input/inputEmail.vue` — exemple d'ajout dynamique.
|
||||
|
||||
**Note hooks pré-commit :** le repo a un hook `make pre-commit` (lint + suite complète ~888 tests) KNOWN FLAKY (timeouts 5000ms intermittents sur des fichiers SANS rapport). Si un commit échoue uniquement sur un timeout de test sans rapport, relancer une fois ; si ça reflake, `git commit --no-verify`. Toujours stager des fichiers explicites — **jamais** `git add -A` (le `nuxt.config.ts` et `.playground/pages/composant/radio/radioButton.vue` modifiés localement ne doivent PAS être committés).
|
||||
|
||||
Le composant de référence est `app/components/malio/input/InputPhone.vue` (le pattern `addable` y est déjà implémenté à l'identique).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `InputEmail.vue` — bouton addable + icône effective
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputEmail.vue`
|
||||
|
||||
Comportement attendu : `addable=false` (défaut) ⇒ rendu strictement inchangé ; `addable=true` ⇒ bouton « + » à droite, icône email à gauche, event `add` émis au clic (sauf `disabled`/`readonly`).
|
||||
|
||||
- [ ] **Step 1 : Ajouter les props `addable`/`addIconName`/`addButtonLabel`**
|
||||
|
||||
Dans `defineProps<{...}>()`, ajouter ces trois lignes juste après `iconColor?: string` :
|
||||
```ts
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
```
|
||||
Dans `withDefaults(..., { ... })`, ajouter juste après `iconColor: 'text-m-muted',` :
|
||||
```ts
|
||||
addable: false,
|
||||
addIconName: 'mdi:plus',
|
||||
addButtonLabel: 'Ajouter une adresse email',
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter l'event `add`**
|
||||
|
||||
Remplacer :
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
```
|
||||
par :
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'add'): void
|
||||
}>()
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter le handler `onAdd`**
|
||||
|
||||
Juste après la fonction `onInput` (après son `}` de fermeture, avant `const iconInputPaddingClass`), ajouter :
|
||||
```ts
|
||||
const onAdd = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
emit('add')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter `effectiveIconPosition` et réécrire `iconInputPaddingClass`**
|
||||
|
||||
Remplacer le bloc actuel :
|
||||
```ts
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
if (!props.iconName) return ''
|
||||
return props.iconPosition === 'left' ? '!pl-11 !pr-3' : '!pl-3 !pr-10'
|
||||
})
|
||||
```
|
||||
par :
|
||||
```ts
|
||||
const effectiveIconPosition = computed(() =>
|
||||
props.addable && props.iconName ? 'left' : props.iconPosition,
|
||||
)
|
||||
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
|
||||
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
|
||||
const parts: string[] = []
|
||||
if (leftIcon) parts.push('!pl-11')
|
||||
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||
return parts.join(' ')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Repointer `labelPositionClass`, `focusPaddingClass`, `iconPositionClass` sur `effectiveIconPosition`**
|
||||
|
||||
Remplacer :
|
||||
```ts
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && props.iconPosition === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = props.iconPosition === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
```
|
||||
par :
|
||||
```ts
|
||||
const labelPositionClass = computed(() => {
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'left-11'
|
||||
return 'left-3'
|
||||
})
|
||||
|
||||
const focusPaddingClass = computed(() => {
|
||||
if (props.iconName && effectiveIconPosition.value === 'left') return 'focus:!pl-11'
|
||||
return 'focus:pl-[11px]'
|
||||
})
|
||||
|
||||
const iconPositionClass = computed(() => {
|
||||
const sideClass = effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'
|
||||
return `pointer-events-none absolute ${sideClass} top-1/2 -translate-y-1/2`
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Ajouter la computed `mergedAddButtonClass`**
|
||||
|
||||
Juste après la computed `mergedLabelClass` (après son `)` de fermeture, avant `const describedBy`), ajouter :
|
||||
```ts
|
||||
const mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||
iconStateClass.value,
|
||||
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 7 : Ajouter le bouton dans le template**
|
||||
|
||||
Dans le template, juste après le bloc `<IconifyIcon v-if="iconName" ... />` (sa balise fermante `/>`) et avant la `</div>` qui ferme le conteneur du champ, insérer :
|
||||
```html
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@click="onAdd"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="addIconName"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="add-icon"
|
||||
/>
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 8 : Vérifier la non-régression**
|
||||
|
||||
Run : `npm run test -- InputEmail.test.ts`
|
||||
Expected : PASS — tous les tests existants passent toujours (le cas `addable=false` est strictement inchangé : icône à droite, paddings identiques).
|
||||
|
||||
- [ ] **Step 9 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputEmail.vue
|
||||
git commit -m "feat(email) : bouton + d'ajout (event add) sur MalioInputEmail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Tests du bouton addable
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/input/InputEmail.test.ts`
|
||||
|
||||
Le fichier utilise déjà un helper `mountComponent(props)` qui stub `IconifyIcon` en `<span data-test="icon" v-bind="$attrs" />`. L'icône email rend `data-test="icon"` ; le `<button>` rend `data-test="add-button"` et son icône interne `data-test="add-icon"` — donc `[data-test="icon"]` ne matche que l'icône email.
|
||||
|
||||
- [ ] **Step 1 : Étendre le type `InputEmailProps`**
|
||||
|
||||
Dans le type `InputEmailProps` (en tête de fichier), ajouter après `lowercase?: boolean` :
|
||||
```ts
|
||||
addable?: boolean
|
||||
addIconName?: string
|
||||
addButtonLabel?: string
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter les tests addable**
|
||||
|
||||
À l'intérieur du `describe('MalioInputEmail', () => { ... })`, juste avant la `})` finale qui ferme ce describe, ajouter :
|
||||
```ts
|
||||
it('does not render add button by default', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders add button when addable is true', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.find('[data-test="add-button"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits add event when add button is clicked', async () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not emit add when disabled', async () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not emit add when readonly', async () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
await wrapper.get('[data-test="add-button"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('add')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables add button when disabled', () => {
|
||||
const wrapper = mountComponent({addable: true, disabled: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('add button is not natively disabled in readonly (onAdd guard blocks the action)', () => {
|
||||
const wrapper = mountComponent({addable: true, readonly: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves the email icon to the left automatically when addable', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
const icon = wrapper.get('[data-test="icon"]')
|
||||
expect(icon.classes()).toContain('left-[10px]')
|
||||
expect(icon.classes()).not.toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('keeps the email icon on the right when addable is false', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('[data-test="icon"]').classes()).toContain('right-[10px]')
|
||||
})
|
||||
|
||||
it('uses the default add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter une adresse email')
|
||||
})
|
||||
|
||||
it('allows overriding the add button aria-label', () => {
|
||||
const wrapper = mountComponent({addable: true, addButtonLabel: 'Ajouter un destinataire'})
|
||||
|
||||
expect(wrapper.get('[data-test="add-button"]').attributes('aria-label')).toBe('Ajouter un destinataire')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer les tests**
|
||||
|
||||
Run : `npm run test -- InputEmail.test.ts`
|
||||
Expected : PASS — tests existants + 11 nouveaux.
|
||||
|
||||
Si le test `moves the email icon to the left` échoue parce que `get('[data-test="icon"]')` trouve plusieurs éléments, c'est que le stub du bouton-icône a rendu `data-test="icon"` au lieu de `add-icon` ; debug en loggant `wrapper.findAll('[data-test="icon"]').length`. Ne PAS affaiblir l'assertion sans comprendre : `data-test="add-icon"` doit primer via `v-bind="$attrs"`.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/input/InputEmail.test.ts
|
||||
git commit -m "test(email) : couvre le bouton + d'ajout de MalioInputEmail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Documentation (COMPONENTS.md + CHANGELOG.md)
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Ajouter les props au tableau `MalioInputEmail`**
|
||||
|
||||
Dans `COMPONENTS.md`, section `## MalioInputEmail`, dans le tableau des props, insérer ces lignes juste après la ligne `| \`iconColor\` | \`string\` | \`'text-m-muted'\` | Classe couleur icône |` :
|
||||
```markdown
|
||||
| `addable` | `boolean` | `false` | Affiche un bouton `+` à droite qui émet l'event `add` (l'icône email passe à gauche) |
|
||||
| `addIconName` | `string` | `'mdi:plus'` | Icône Iconify du bouton d'ajout |
|
||||
| `addButtonLabel` | `string` | `'Ajouter une adresse email'` | aria-label du bouton d'ajout |
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Documenter l'event `add` et ajouter un exemple**
|
||||
|
||||
Dans la même section, remplacer la ligne :
|
||||
```markdown
|
||||
**Events :** `update:modelValue(value: string)`
|
||||
```
|
||||
par :
|
||||
```markdown
|
||||
**Events :**
|
||||
- `update:modelValue(value: string)`
|
||||
- `add()` — émis au clic du bouton `+` (uniquement si `addable`, non `disabled`, non `readonly`)
|
||||
```
|
||||
Puis, dans le bloc d'exemple ```vue de cette section, ajouter cette ligne juste avant la fence fermante ``` :
|
||||
```vue
|
||||
<MalioInputEmail v-model="email" label="Email" addable @add="addEmailField" />
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'entrée CHANGELOG**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `### Added`, ajouter comme dernière puce de la liste (juste après `* [#MUI-41] InputEmail : sanitisation à la saisie ...`) :
|
||||
```markdown
|
||||
* InputEmail : bouton `+` d'ajout optionnel (prop `addable`, event `add`), calqué sur InputPhone ; l'icône email passe à gauche quand le bouton est actif
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(email) : documente le bouton + d'ajout de MalioInputEmail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Story + playground
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/story/input/inputEmail.story.vue`
|
||||
- Modify: `.playground/pages/composant/input/inputEmail.vue`
|
||||
|
||||
- [ ] **Step 1 : Ajouter une carte « addable » dans la story**
|
||||
|
||||
Dans `app/story/input/inputEmail.story.vue`, juste après la carte « Icône à gauche » (le `<div class="rounded-lg border p-4">` qui se termine ligne 19, contenant `icon-position="left"`) et avant la carte « Sans icône », insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Avec bouton « ajouter »</h2>
|
||||
<MalioInputEmail
|
||||
v-model="addableValue"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="onAdd"
|
||||
/>
|
||||
<p v-if="addClicks > 0" class="mt-2 text-sm text-m-muted">
|
||||
Bouton cliqué {{ addClicks }} fois
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Déclarer les refs/handler dans le `<script setup>` de la story**
|
||||
|
||||
Dans le `<script setup>` de `app/story/input/inputEmail.story.vue`, après la ligne `const simpleValue = ref('')`, ajouter :
|
||||
```ts
|
||||
const addableValue = ref('')
|
||||
const addClicks = ref(0)
|
||||
const onAdd = () => { addClicks.value += 1 }
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter un exemple d'ajout dynamique dans le playground**
|
||||
|
||||
Dans `.playground/pages/composant/input/inputEmail.vue`, juste après la carte « Avec label » (le `<div class="rounded-lg border p-4">` qui se termine ligne 15) et avant la carte « Icône à gauche », insérer :
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Ajout dynamique (bouton +)</h2>
|
||||
<div class="space-y-3">
|
||||
<MalioInputEmail
|
||||
v-for="(email, index) in emails"
|
||||
:key="index"
|
||||
v-model="emails[index]"
|
||||
label="Adresse email"
|
||||
addable
|
||||
@add="emails.push('')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Déclarer la ref dans le `<script setup>` du playground**
|
||||
|
||||
Dans le `<script setup>` de `.playground/pages/composant/input/inputEmail.vue`, après la ligne `const emailValue = ref('')`, ajouter :
|
||||
```ts
|
||||
const emails = ref<string[]>([''])
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier le lint**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur sur les deux fichiers modifiés (des warnings pré-existants sur d'AUTRES fichiers sont tolérés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/story/input/inputEmail.story.vue .playground/pages/composant/input/inputEmail.vue
|
||||
git commit -m "docs(email) : exemples bouton + d'ajout (story + playground)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 : Suite InputEmail**
|
||||
|
||||
Run : `npm run test -- InputEmail.test.ts`
|
||||
Expected : PASS (existants + 11 nouveaux).
|
||||
|
||||
- [ ] **Step 2 : Lint global**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : 0 erreur.
|
||||
|
||||
- [ ] **Step 3 : Vérification manuelle (recommandée)**
|
||||
|
||||
Run : `npm run dev`, ouvrir `composant/input/inputEmail`.
|
||||
Vérifier :
|
||||
- Carte « Ajout dynamique » : cliquer « + » ajoute un nouveau champ email en dessous.
|
||||
- Avec `addable`, l'icône email est à gauche et le « + » à droite, sans chevauchement.
|
||||
- Le bouton « + » est grisé/inactif en `disabled`.
|
||||
- Les autres cartes email (sans `addable`) sont inchangées (icône à droite).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Props `addable`/`addIconName`/`addButtonLabel` (défauts `false`/`'mdi:plus'`/`'Ajouter une adresse email'`) → Task 1 Step 1.
|
||||
- Event `add` → Task 1 Step 2.
|
||||
- `effectiveIconPosition` (icône à gauche si addable) + 4 computeds repointées → Task 1 Steps 4-5.
|
||||
- `iconInputPaddingClass` aligné Phone (pr-10 si addable) → Task 1 Step 4.
|
||||
- Bouton template + `mergedAddButtonClass` + `onAdd` (garde disabled/readonly) → Task 1 Steps 3, 6, 7.
|
||||
- Logique email existante intacte (`onInput`/`sanitizeEmail`/`lowercase` non touchés) → aucune tâche ne les modifie.
|
||||
- Tests (présence, émission, gardes disabled/readonly, repositionnement icône, libellé) → Task 2.
|
||||
- Docs COMPONENTS.md + CHANGELOG.md → Task 3 ; story + playground → Task 4.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni intégralement.
|
||||
|
||||
**Type consistency :** `addable`/`addIconName`/`addButtonLabel` (props), `add` (event), `onAdd`/`effectiveIconPosition`/`mergedAddButtonClass`/`iconStateClass` (composant) — noms cohérents entre tâches. Les `data-test` (`add-button`, `add-icon`, `icon`) concordent entre composant (Task 1) et tests (Task 2). `iconStateClass` et `twMerge` existent déjà dans `InputEmail.vue`.
|
||||
@@ -1,635 +0,0 @@
|
||||
# MalioDate — saisie manuelle au clavier — 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:** Permettre la saisie clavier `JJ/MM/AAAA` dans `MalioDate` (opt-in via prop `editable`), en plus de la sélection au calendrier, avec validation au blur et état d'erreur visuel.
|
||||
|
||||
**Architecture:** `CalendarField` (interne, partagé) gagne un mode `editable` : input non `readonly`, masque `maska`, buffer local `draft` synchronisé sur `displayValue`, émission d'un event `commit(text)` au blur / à Entrée. `MalioDate` conserve toute la logique date : parse (`parseDisplayToIso`), validation bornes (`isDateInRange`), état d'erreur interne fusionné avec la prop `error` du consommateur. `CalendarField` reste agnostique au format.
|
||||
|
||||
**Tech Stack:** Nuxt 4 layer, Vue 3 `<script setup lang="ts">`, `maska` (directive `v-maska`), `tailwind-merge`, Vitest + `@vue/test-utils` (jsdom).
|
||||
|
||||
**Référence spec :** `docs/superpowers/specs/2026-06-09-maliodate-saisie-manuelle-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `app/components/malio/date/internal/CalendarField.vue` — mode `editable` : prop, masque, buffer `draft`, handlers focus/input/blur/enter, event `commit`.
|
||||
- **Modify** `app/components/malio/date/Date.vue` — props `editable` / `invalidMessage`, état `internalError`, handler `onCommit`, fusion `mergedError`, nettoyage erreur à la sélection/clear.
|
||||
- **Modify** `app/components/malio/date/Date.test.ts` — tests de saisie manuelle + non-régression.
|
||||
- **Modify** `COMPONENTS.md` — documentation des props.
|
||||
- **Modify** `CHANGELOG.md` — entrée de version.
|
||||
- **Modify** `.playground/pages/composant/date/date.vue` — exemple éditable.
|
||||
- **Modify** `app/story/date/datePicker.story.vue` — exemple éditable.
|
||||
|
||||
**Note hooks pré-commit :** le projet a un hook `make pre-commit` (lint + 888 tests) parfois lent/flaky. Si un commit échoue sur un timeout de test sans rapport, relancer ; en dernier recours `--no-verify`. Toujours stager des fichiers explicites, **jamais** `git add -A` (le `nuxt.config.ts` modifié localement ne doit pas être committé).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : `CalendarField` — prop `editable`, masque et buffer
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/internal/CalendarField.vue`
|
||||
|
||||
Cette tâche ajoute l'infrastructure du mode éditable. On la valide via les tests de la Task 3 (le comportement observable passe par `MalioDate`). Ici on vérifie surtout la non-régression : `editable=false` ⇒ input `readonly`, valeur affichée intacte.
|
||||
|
||||
- [ ] **Step 1 : Ajouter les imports `maska`**
|
||||
|
||||
Dans le bloc `<script setup>`, juste après la ligne `import {twMerge} from 'tailwind-merge'` (ligne 104), ajouter :
|
||||
|
||||
```ts
|
||||
import {vMaska} from 'maska/vue'
|
||||
import type {MaskInputOptions} from 'maska'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter la prop `editable` à l'interface et aux défauts**
|
||||
|
||||
Dans `defineProps<{...}>()`, ajouter la ligne après `clearable?: boolean` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
```
|
||||
|
||||
Dans le bloc `withDefaults(..., { ... })`, ajouter après `clearable: true,` :
|
||||
|
||||
```ts
|
||||
editable: false,
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Déclarer l'event `commit`**
|
||||
|
||||
Remplacer la ligne (≈152) :
|
||||
|
||||
```ts
|
||||
const emit = defineEmits<{(e: 'clear' | 'close'): void}>()
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(e: 'clear' | 'close'): void
|
||||
(e: 'commit', value: string): void
|
||||
}>()
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter le buffer `draft`, le masque et l'état `readonly` calculé**
|
||||
|
||||
Juste après la ligne `const root = ref<HTMLElement | null>(null)` (≈156), ajouter :
|
||||
|
||||
```ts
|
||||
const draft = ref(props.displayValue)
|
||||
const maskaOptions = computed<MaskInputOptions>(() => ({mask: props.editable ? '##/##/####' : undefined}))
|
||||
const inputReadonly = computed(() => !props.editable || props.readonly || props.disabled)
|
||||
|
||||
watch(() => props.displayValue, (value) => {
|
||||
draft.value = value
|
||||
})
|
||||
```
|
||||
|
||||
(Note : `mask: undefined` désactive le masquage de `maska` — la valeur passe intacte. Ne **pas** utiliser `''`, qui viderait la valeur.)
|
||||
|
||||
- [ ] **Step 5 : Mettre à jour le computed `isFilled` pour tenir compte du buffer**
|
||||
|
||||
Remplacer (≈164) :
|
||||
|
||||
```ts
|
||||
const isFilled = computed(() => props.displayValue.length > 0)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const isFilled = computed(() =>
|
||||
(props.editable ? draft.value.length : props.displayValue.length) > 0,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Remplacer `onFieldClick` et ajouter les handlers éditables**
|
||||
|
||||
Remplacer le bloc `onFieldClick` (≈177-185) :
|
||||
|
||||
```ts
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const onFieldClick = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
if (props.editable) {
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isOpen.value) {
|
||||
closePopover()
|
||||
return
|
||||
}
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (props.disabled || props.readonly || !props.editable) return
|
||||
if (!isOpen.value) {
|
||||
syncToIso(props.syncTo)
|
||||
open()
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
draft.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
}
|
||||
|
||||
const onEnter = () => {
|
||||
if (!props.editable) return
|
||||
emit('commit', draft.value)
|
||||
closePopover()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7 : Mettre à jour l'`<input>` dans le template**
|
||||
|
||||
Remplacer le bloc `<input>` (≈7-25) :
|
||||
|
||||
```html
|
||||
<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"
|
||||
>
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```html
|
||||
<input
|
||||
:id="inputId"
|
||||
v-maska="maskaOptions"
|
||||
:name="name"
|
||||
data-test="date-input"
|
||||
:readonly="inputReadonly"
|
||||
autocomplete="off"
|
||||
:class="mergedInputClass"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:value="editable ? draft : displayValue"
|
||||
:aria-invalid="!!error"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
v-bind="attrs"
|
||||
placeholder="_"
|
||||
type="text"
|
||||
@click="onFieldClick"
|
||||
@focus="onFocus"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 8 : Lancer la suite Date pour vérifier la non-régression**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (tous les tests existants de `MalioDate` passent toujours ; l'input par défaut reste `readonly` et affiche la valeur formatée).
|
||||
|
||||
- [ ] **Step 9 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/internal/CalendarField.vue
|
||||
git commit -m "feat(date) : mode editable dans CalendarField (saisie clavier)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : `MalioDate` — parsing, validation et état d'erreur
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/Date.vue`
|
||||
|
||||
- [ ] **Step 1 : Étendre les imports de `dateFormat`**
|
||||
|
||||
Remplacer (≈39) :
|
||||
|
||||
```ts
|
||||
import {formatIsoToDisplay, isValidIso} from './composables/dateFormat'
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
import {formatIsoToDisplay, isDateInRange, isValidIso, parseDisplayToIso} from './composables/dateFormat'
|
||||
```
|
||||
|
||||
Et compléter l'import Vue (≈36) pour disposer de `ref` :
|
||||
|
||||
```ts
|
||||
import {computed, ref, watch} from 'vue'
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter les props `editable` et `invalidMessage`**
|
||||
|
||||
Dans `defineProps<{...}>()`, ajouter après `clearable?: boolean` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
```
|
||||
|
||||
Dans `withDefaults(..., { ... })`, ajouter après `clearable: true,` :
|
||||
|
||||
```ts
|
||||
editable: false,
|
||||
invalidMessage: 'Date invalide',
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'état d'erreur interne, la fusion, et les handlers**
|
||||
|
||||
Juste après la ligne `const displayValue = computed(() => formatIsoToDisplay(props.modelValue ?? null))` (≈86), ajouter :
|
||||
|
||||
```ts
|
||||
const internalError = ref('')
|
||||
const mergedError = computed(() => props.error || internalError.value)
|
||||
|
||||
const onCommit = (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = parseDisplayToIso(trimmed)
|
||||
if (iso && isDateInRange(iso, props.min, props.max)) {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
return
|
||||
}
|
||||
internalError.value = props.invalidMessage
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
|
||||
const onSelect = (iso: string, close: () => void) => {
|
||||
internalError.value = ''
|
||||
emit('update:modelValue', iso)
|
||||
close()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Brancher les props et events sur `CalendarField` dans le template**
|
||||
|
||||
Dans `<CalendarField ...>`, remplacer `:error="error"` (≈13) par :
|
||||
|
||||
```html
|
||||
:error="mergedError"
|
||||
```
|
||||
|
||||
Ajouter, juste après `:clearable="clearable"` (≈15) :
|
||||
|
||||
```html
|
||||
:editable="editable"
|
||||
```
|
||||
|
||||
Remplacer `@clear="emit('update:modelValue', null)"` (≈20) par :
|
||||
|
||||
```html
|
||||
@clear="onClear"
|
||||
@commit="onCommit"
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Brancher la sélection calendrier sur `onSelect`**
|
||||
|
||||
Remplacer (≈29) :
|
||||
|
||||
```html
|
||||
@select="(iso) => { emit('update:modelValue', iso); close() }"
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```html
|
||||
@select="(iso) => onSelect(iso, close)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Lancer la suite Date pour vérifier la non-régression**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (les tests existants passent ; `mergedError` se comporte comme `error` tant qu'aucune saisie invalide n'est faite).
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/Date.vue
|
||||
git commit -m "feat(date) : saisie manuelle MalioDate (parse, validation, erreur)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Tests de la saisie manuelle
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/malio/date/Date.test.ts`
|
||||
|
||||
- [ ] **Step 1 : Étendre le type de props de test**
|
||||
|
||||
Dans le type `DateProps` (≈6-25), ajouter après `groupClass?: string` :
|
||||
|
||||
```ts
|
||||
editable?: boolean
|
||||
invalidMessage?: string
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire le bloc de tests `saisie manuelle (editable)`**
|
||||
|
||||
Ajouter, juste avant la fermeture du `describe('MalioDate', ...)` (avant la dernière `})` du fichier, après le bloc `describe('reserveMessageSpace', ...)`), le bloc suivant :
|
||||
|
||||
```ts
|
||||
describe('saisie manuelle (editable)', () => {
|
||||
it('par défaut (editable=false) l\'input reste readonly et affiche la valeur', () => {
|
||||
const wrapper = mountDate({modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
expect(input.attributes('readonly')).toBeDefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('19/05/2026')
|
||||
})
|
||||
|
||||
it('editable=true : l\'input n\'est plus readonly', () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
expect(wrapper.get('[data-test="date-input"]').attributes('readonly')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('émet l\'ISO sur saisie clavier valide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
})
|
||||
|
||||
it('garde le texte et affiche « Date invalide » sur saisie invalide au blur', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect((input.element as HTMLInputElement).value).toBe('32/13/2026')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('passe en erreur si la date saisie est hors min/max', async () => {
|
||||
const wrapper = mountDate({editable: true, min: '2026-05-10', max: '2026-05-20'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('25/12/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('émet null sur saisie vidée au blur', async () => {
|
||||
const wrapper = mountDate({editable: true, modelValue: '2026-05-19'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('efface l\'erreur de saisie quand on sélectionne une date au calendrier', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('32/13/2026')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Date invalide')
|
||||
await input.trigger('focus')
|
||||
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.text()).not.toContain('Date invalide')
|
||||
})
|
||||
|
||||
it('valide et ferme le popover sur Entrée', async () => {
|
||||
const wrapper = mountDate({editable: true})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.trigger('focus')
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(true)
|
||||
await input.setValue('19/05/2026')
|
||||
await input.trigger('keydown.enter')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['2026-05-19'])
|
||||
expect(wrapper.find('[data-test="popover"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('utilise le message invalidMessage personnalisé', async () => {
|
||||
const wrapper = mountDate({editable: true, invalidMessage: 'Format incorrect'})
|
||||
const input = wrapper.get('[data-test="date-input"]')
|
||||
await input.setValue('99/99/9999')
|
||||
await input.trigger('blur')
|
||||
expect(wrapper.text()).toContain('Format incorrect')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Lancer les nouveaux tests**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS (tous, anciens + nouveaux).
|
||||
|
||||
Si un test de saisie échoue parce que `maska` a reformaté la valeur en jsdom autrement qu'attendu, inspecter la valeur réelle via un `console.log((input.element as HTMLInputElement).value)` et ajuster l'assertion (le masque `##/##/####` laisse les chiffres tels quels ; une entrée déjà bien formée n'est pas modifiée).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add app/components/malio/date/Date.test.ts
|
||||
git commit -m "test(date) : couvre la saisie manuelle de MalioDate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Documentation (COMPONENTS.md + CHANGELOG.md)
|
||||
|
||||
**Files:**
|
||||
- Modify: `COMPONENTS.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 1 : Ajouter les props au tableau `MalioDate` de `COMPONENTS.md`**
|
||||
|
||||
Dans la section `## MalioDate`, dans le tableau des props, insérer juste après la ligne `| `clearable` | `boolean` | `true` | Affiche la croix d'effacement |` :
|
||||
|
||||
```markdown
|
||||
| `editable` | `boolean` | `false` | Autorise la saisie clavier `JJ/MM/AAAA` (masque maska, validation au blur) en plus du calendrier |
|
||||
| `invalidMessage` | `string` | `'Date invalide'` | Message affiché quand la saisie clavier est invalide ou hors `min`/`max` |
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Compléter la description et l'exemple `MalioDate`**
|
||||
|
||||
Dans la section `## MalioDate`, juste après la ligne de description `La valeur est une chaîne ISO ...`, ajouter le paragraphe :
|
||||
|
||||
```markdown
|
||||
Avec `editable`, l'utilisateur peut aussi taper la date au clavier. La valeur n'est émise qu'au blur (ou sur Entrée) si elle est valide et dans les bornes ; sinon le texte est conservé et le champ passe en erreur (`invalidMessage`).
|
||||
```
|
||||
|
||||
Dans le bloc d'exemple ```vue de cette section, ajouter une ligne avant la fermeture ``` :
|
||||
|
||||
```vue
|
||||
<MalioDate v-model="date" label="Date de naissance" editable />
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'entrée CHANGELOG**
|
||||
|
||||
Dans `CHANGELOG.md`, sous `### Added`, ajouter à la fin de la liste (après la dernière puce `* [#MUI-41] InputEmail : ...`) :
|
||||
|
||||
```markdown
|
||||
* [#MUI-42] MalioDate : saisie clavier `JJ/MM/AAAA` optionnelle (prop `editable`, masque maska, validation au blur, message `invalidMessage`)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add COMPONENTS.md CHANGELOG.md
|
||||
git commit -m "docs(date) : documente la saisie manuelle de MalioDate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Exemples playground + story
|
||||
|
||||
**Files:**
|
||||
- Modify: `.playground/pages/composant/date/date.vue`
|
||||
- Modify: `app/story/date/datePicker.story.vue`
|
||||
|
||||
- [ ] **Step 1 : Ajouter un bloc éditable dans la page playground**
|
||||
|
||||
Dans `.playground/pages/composant/date/date.vue`, dans la première colonne `Large (480px)`, juste après le `<div class="rounded border p-3 text-sm">...</div>` qui affiche la valeur ISO (≈13-15), ajouter :
|
||||
|
||||
```html
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date (saisie clavier)"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
<div class="rounded border p-3 text-sm">
|
||||
<p>Valeur éditable (ISO) : <code>{{ editableValue ?? 'null' }}</code></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Déclarer la ref dans le `<script setup>` de la page playground**
|
||||
|
||||
Dans le `<script setup>` du même fichier, après `const bounded = ref<string | null>(null)`, ajouter :
|
||||
|
||||
```ts
|
||||
const editableValue = ref<string | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter un exemple éditable dans la story**
|
||||
|
||||
Dans `app/story/date/datePicker.story.vue`, ajouter une nouvelle carte juste après le bloc `<!-- Avec min/max -->` (le `<div class="rounded-lg border p-4">` qui contient « Avec min/max »), avant le bloc « Non effaçable » :
|
||||
|
||||
```html
|
||||
<div class="rounded-lg border p-4">
|
||||
<h2 class="mb-4 text-xl font-bold">Saisie clavier (editable)</h2>
|
||||
<MalioDate
|
||||
v-model="editableValue"
|
||||
label="Date de naissance"
|
||||
editable
|
||||
hint="Tape JJ/MM/AAAA ou utilise le calendrier"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Déclarer la ref dans le `<script setup>` de la story**
|
||||
|
||||
Dans le `<script setup>` du même fichier, après `const errorValue = ref<string | null>(null)`, ajouter :
|
||||
|
||||
```ts
|
||||
const editableValue = ref<string | null>(null)
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier que rien ne casse (lint + build des types)**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : PASS (aucune erreur sur les fichiers modifiés).
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add .playground/pages/composant/date/date.vue app/story/date/datePicker.story.vue
|
||||
git commit -m "docs(date) : exemples saisie manuelle (playground + story)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Vérification finale
|
||||
|
||||
- [ ] **Step 1 : Lancer toute la suite de tests**
|
||||
|
||||
Run : `npm run test -- Date.test.ts`
|
||||
Expected : PASS — l'ensemble du fichier (anciens + 9 nouveaux tests).
|
||||
|
||||
- [ ] **Step 2 : Lancer le lint global**
|
||||
|
||||
Run : `npm run lint`
|
||||
Expected : PASS.
|
||||
|
||||
- [ ] **Step 3 : Vérification manuelle dans le playground (optionnel mais recommandé)**
|
||||
|
||||
Run : `npm run dev` puis ouvrir la page `composant/date`.
|
||||
Vérifier :
|
||||
- Taper `19/05/2026` puis cliquer ailleurs → la valeur ISO affichée devient `2026-05-19`.
|
||||
- Taper `32/13/2026` puis blur → le texte reste, le champ passe en rouge avec « Date invalide ».
|
||||
- Avec une saisie invalide, ouvrir le calendrier et choisir un jour → l'erreur disparaît, la valeur se met à jour.
|
||||
- Le focus dans le champ ouvre bien le calendrier, et taper reste possible.
|
||||
- Sur le champ `editable=false` existant : aucun changement (lecture seule).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage :**
|
||||
- Prop `editable` opt-in (défaut false) → Task 1 Step 2, Task 2 Step 2.
|
||||
- Masque `##/##/####` + focus ouvre le popover → Task 1 Steps 4/6/7.
|
||||
- Validation au blur, pas à la frappe → Task 1 (onInput ne valide pas) + Task 2 (onCommit).
|
||||
- Saisie invalide : garde le texte + erreur visuelle → Task 2 Step 3 + Task 3 test dédié.
|
||||
- Message par défaut « Date invalide », surchargeable → Task 2 Step 2.
|
||||
- Touche Entrée commit + ferme popover → Task 1 Step 6 (`onEnter`) + Task 3 test.
|
||||
- Hors min/max = invalide → Task 2 (`isDateInRange`) + Task 3 test.
|
||||
- Sélection calendrier efface l'erreur → Task 2 Step 5 (`onSelect`) + Task 3 test.
|
||||
- `disabled`/`readonly` priment → Task 1 (`inputReadonly`, gardes dans handlers).
|
||||
- Non-régression `editable=false` → Task 1 Step 8 + Task 3 test readonly.
|
||||
- Docs COMPONENTS.md + CHANGELOG.md + playground/story → Tasks 4 et 5.
|
||||
|
||||
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni intégralement.
|
||||
|
||||
**Type consistency :** `editable`/`invalidMessage` (props), `commit` (event CalendarField), `onCommit`/`onClear`/`onSelect`/`internalError`/`mergedError` (MalioDate), `draft`/`maskaOptions`/`inputReadonly`/`onFocus`/`onInput`/`onBlur`/`onEnter` (CalendarField) — noms cohérents entre tâches. `onCommit(text: string)` correspond à l'event `commit(value: string)`. `onSelect(iso: string, close: () => void)` correspond à la signature du slot (`close` exposé par `CalendarField`).
|
||||
@@ -0,0 +1,228 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sandbox — Pagination DataTable (proposition)</title>
|
||||
<style>
|
||||
:root{
|
||||
--m-primary:#222783; --m-primary-hover:#121cdb; --m-primary-light:#efeffd;
|
||||
--m-bg:#f3f4f8; --m-text:#0f172a; --m-muted:#64748b; --m-border:#cbd5e1; --m-radius:6px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:var(--m-text);background:var(--m-bg);line-height:1.5}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:32px 20px 64px}
|
||||
h1{font-size:22px;margin:0 0 4px}
|
||||
.sub{color:var(--m-muted);margin:0 0 28px}
|
||||
.card{background:#fff;border:1px solid var(--m-border);border-radius:10px;padding:20px 22px;margin-bottom:22px}
|
||||
.card h2{font-size:15px;margin:0 0 14px;letter-spacing:.01em}
|
||||
.muted{color:var(--m-muted)}
|
||||
.small{font-size:13px}
|
||||
code{background:var(--m-primary-light);color:var(--m-primary);padding:1px 6px;border-radius:4px;font-size:13px}
|
||||
|
||||
/* ----- pagination bar (proposition) ----- */
|
||||
.pagination{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.btn{
|
||||
height:30px;padding:0 12px;font-size:14px;border-radius:var(--m-radius);
|
||||
border:1px solid var(--m-border);background:#fff;color:var(--m-text);cursor:pointer;
|
||||
display:inline-flex;align-items:center;transition:background .12s,border-color .12s,color .12s;
|
||||
}
|
||||
.btn:hover:not(:disabled){border-color:var(--m-primary);color:var(--m-primary)}
|
||||
.btn:disabled{opacity:.45;cursor:not-allowed}
|
||||
.jump{display:inline-flex;align-items:center;gap:8px;font-size:14px}
|
||||
.jump label{color:var(--m-muted)}
|
||||
.jump input{
|
||||
width:58px;height:30px;text-align:center;font-size:14px;border:1px solid var(--m-border);
|
||||
border-radius:var(--m-radius);outline:none;color:var(--m-text);
|
||||
}
|
||||
.jump input:focus{border-color:var(--m-primary);box-shadow:0 0 0 2px var(--m-primary-light)}
|
||||
.jump .total{color:var(--m-muted)}
|
||||
|
||||
.perpage{display:inline-flex;align-items:center;gap:8px;font-size:14px;color:var(--m-muted)}
|
||||
.perpage select{height:30px;border:1px solid var(--m-border);border-radius:var(--m-radius);padding:0 8px;color:var(--m-text)}
|
||||
|
||||
/* ----- "avant" (état actuel) ----- */
|
||||
.old{display:flex;align-items:center;gap:6px;opacity:.7;flex-wrap:wrap}
|
||||
.old .pg{height:30px;min-width:38px;padding:0 8px;display:inline-flex;align-items:center;justify-content:center;border-radius:6px;font-size:14px;border:1px solid transparent}
|
||||
.old .pg.cur{background:var(--m-primary);color:#fff;font-weight:600}
|
||||
.old .pg.btn-like{border:1px solid var(--m-border)}
|
||||
.old .dots{color:var(--m-muted);padding:0 2px}
|
||||
|
||||
.controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:6px}
|
||||
.controls label{font-size:13px;color:var(--m-muted);display:inline-flex;gap:6px;align-items:center}
|
||||
.controls input,.controls select{height:28px;border:1px solid var(--m-border);border-radius:6px;padding:0 8px}
|
||||
|
||||
.log{margin-top:14px;border-top:1px dashed var(--m-border);padding-top:12px}
|
||||
.log h3{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--m-muted);margin:0 0 8px}
|
||||
.log ul{list-style:none;margin:0;padding:0;max-height:150px;overflow:auto;font-size:13px}
|
||||
.log li{padding:3px 0;border-bottom:1px solid #f1f5f9;display:flex;justify-content:space-between;gap:12px}
|
||||
.log li .t{color:var(--m-muted);font-variant-numeric:tabular-nums}
|
||||
.badge{display:inline-block;background:var(--m-primary-light);color:var(--m-primary);font-size:12px;padding:2px 8px;border-radius:999px;margin-left:6px}
|
||||
ul.notes{margin:8px 0 0;padding-left:18px}
|
||||
ul.notes li{margin:3px 0;font-size:13px;color:var(--m-muted)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Pagination DataTable — proposition « aller à la page »</h1>
|
||||
<p class="sub">Maquette interactive pour validation métier. Aucun code définitif — sert à valider le comportement avant développement.</p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Avant — état actuel <span class="badge">existant</span></h2>
|
||||
<div class="old" id="old-bar"></div>
|
||||
<ul class="notes">
|
||||
<li>Boutons Préc. / numéros / « … » / Suiv. Pour aller loin (ex. page 16 sur 31), il faut cliquer plusieurs fois ou viser un numéro.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Après — proposition <span class="badge">nouveau</span></h2>
|
||||
|
||||
<div class="controls">
|
||||
<label>Nombre de pages
|
||||
<input id="cfg-pages" type="number" min="1" value="31" style="width:70px">
|
||||
</label>
|
||||
<label>Délai debounce
|
||||
<select id="cfg-delay">
|
||||
<option value="300">300 ms</option>
|
||||
<option value="400" selected>400 ms</option>
|
||||
<option value="600">600 ms</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<span class="perpage">
|
||||
Lignes :
|
||||
<select disabled><option>25</option></select>
|
||||
</span>
|
||||
|
||||
<button class="btn" id="prev">‹ Préc.</button>
|
||||
|
||||
<span class="jump">
|
||||
<label for="page-input">Page</label>
|
||||
<input id="page-input" type="text" inputmode="numeric" value="1" aria-label="Aller à la page">
|
||||
<span class="total">/ <span id="total">31</span></span>
|
||||
</span>
|
||||
|
||||
<button class="btn" id="next">Suiv. ›</button>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li>Taper un numéro l'applique après <strong id="delay-label">400 ms</strong> (debounce) — seules les valeurs valides <code>1..N</code> partent en cours de frappe.</li>
|
||||
<li><strong>Entrée</strong> applique immédiatement (court-circuite le debounce).</li>
|
||||
<li>Valeur > N → on va à la dernière page (clamp). Champ vidé / 0 → on restaure la page courante.</li>
|
||||
</ul>
|
||||
|
||||
<div class="log">
|
||||
<h3>Journal des « chargements de données » (1 ligne = 1 appel serveur simulé)</h3>
|
||||
<ul id="log"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small muted">Astuce démo : tape <code>16</code> d'un trait → un seul chargement (page 16). Tape lentement <code>3</code> … <code>1</code> → tu verras un chargement intermédiaire page 3, puis page 31 : c'est l'effet « préfixe valide » expliqué au métier.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var pages = 31, page = 1, delay = 400;
|
||||
var timer = null;
|
||||
|
||||
var input = document.getElementById('page-input');
|
||||
var totalEl = document.getElementById('total');
|
||||
var prev = document.getElementById('prev');
|
||||
var next = document.getElementById('next');
|
||||
var logEl = document.getElementById('log');
|
||||
var cfgPages = document.getElementById('cfg-pages');
|
||||
var cfgDelay = document.getElementById('cfg-delay');
|
||||
var delayLabel = document.getElementById('delay-label');
|
||||
|
||||
function now(){
|
||||
var d = new Date();
|
||||
return ('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2)+'.'+('00'+d.getMilliseconds()).slice(-3);
|
||||
}
|
||||
function loadData(p){
|
||||
var li = document.createElement('li');
|
||||
li.innerHTML = '<span>Chargement page <strong>'+p+'</strong></span><span class="t">'+now()+'</span>';
|
||||
logEl.insertBefore(li, logEl.firstChild);
|
||||
}
|
||||
function render(){
|
||||
totalEl.textContent = pages;
|
||||
input.value = page;
|
||||
prev.disabled = page <= 1;
|
||||
next.disabled = page >= pages;
|
||||
renderOld();
|
||||
}
|
||||
// commit a page change (clamped), simulate server load if it actually changes
|
||||
function goTo(p, opts){
|
||||
opts = opts || {};
|
||||
if (isNaN(p)) { input.value = page; return; } // not a number → restore
|
||||
p = Math.min(Math.max(1, Math.round(p)), pages); // clamp
|
||||
if (p !== page){ page = p; loadData(page); }
|
||||
if (!opts.keepInput) render();
|
||||
else { totalEl.textContent = pages; prev.disabled = page<=1; next.disabled = page>=pages; }
|
||||
}
|
||||
|
||||
// live (debounced) — only fires for in-range values
|
||||
input.addEventListener('input', function(){
|
||||
input.value = input.value.replace(/[^0-9]/g,''); // digits only
|
||||
if (timer) clearTimeout(timer);
|
||||
var raw = input.value;
|
||||
if (raw === '') return; // wait, restore on blur
|
||||
var n = parseInt(raw, 10);
|
||||
if (n >= 1 && n <= pages){
|
||||
timer = setTimeout(function(){ goTo(n, {keepInput:true}); }, delay);
|
||||
}
|
||||
});
|
||||
// Enter → immediate
|
||||
input.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Enter'){ if (timer) clearTimeout(timer); goTo(parseInt(input.value,10)); input.select(); }
|
||||
});
|
||||
// blur → commit / restore
|
||||
input.addEventListener('blur', function(){
|
||||
if (timer) clearTimeout(timer);
|
||||
if (input.value === '' ) { input.value = page; return; }
|
||||
goTo(parseInt(input.value,10));
|
||||
});
|
||||
|
||||
prev.addEventListener('click', function(){ goTo(page-1); });
|
||||
next.addEventListener('click', function(){ goTo(page+1); });
|
||||
|
||||
cfgPages.addEventListener('input', function(){
|
||||
var v = parseInt(cfgPages.value,10); if(!v||v<1) return;
|
||||
pages = v; if (page>pages) page=pages; render();
|
||||
});
|
||||
cfgDelay.addEventListener('change', function(){
|
||||
delay = parseInt(cfgDelay.value,10);
|
||||
delayLabel.innerHTML = delay+' ms';
|
||||
});
|
||||
|
||||
// ---- "avant" rendering (numbered + ellipsis), mirrors current logic ----
|
||||
function visiblePages(total, current){
|
||||
if (total <= 5) return Array.from({length:total},function(_,i){return i+1;});
|
||||
var out=[1];
|
||||
if (current>3) out.push('…');
|
||||
var s=Math.max(2,current-1), e=Math.min(total-1,current+1);
|
||||
for(var i=s;i<=e;i++) out.push(i);
|
||||
if (current<total-2) out.push('…');
|
||||
if (total>1) out.push(total);
|
||||
return out;
|
||||
}
|
||||
function renderOld(){
|
||||
var bar = document.getElementById('old-bar');
|
||||
bar.innerHTML='';
|
||||
var prevB=document.createElement('span'); prevB.className='pg btn-like'; prevB.textContent='‹ Préc.'; bar.appendChild(prevB);
|
||||
visiblePages(pages,page).forEach(function(p){
|
||||
var el=document.createElement('span');
|
||||
if(p==='…'){ el.className='dots'; el.textContent='…'; }
|
||||
else { el.className='pg'+(p===page?' cur':''); el.textContent=p; }
|
||||
bar.appendChild(el);
|
||||
});
|
||||
var nextB=document.createElement('span'); nextB.className='pg btn-like'; nextB.textContent='Suiv. ›'; bar.appendChild(nextB);
|
||||
}
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,148 @@
|
||||
# DataTable — pagination « aller à la page » (champ compact)
|
||||
|
||||
**Date :** 2026-06-09
|
||||
**Statut :** Validé (maquette à confirmer en atelier métier), prêt pour plan d'implémentation
|
||||
**Périmètre :** `MalioDataTable` (bloc pagination) uniquement.
|
||||
**Branche :** `feature/datatable-pagination-goto` (isolée de `develop`) — l'existant (numéros + `…`) reste en place sur `feature/MUI-42` le temps de l'atelier métier.
|
||||
|
||||
## Objectif
|
||||
|
||||
Remplacer la pagination numérotée (`Préc. 1 … 15 16 17 … 31 Suiv.`) par une forme **compacte avec saisie directe du numéro de page** : `‹ Préc. Page [16] / 31 Suiv. ›`. Le client veut pouvoir aller directement à une page en tapant son numéro.
|
||||
|
||||
Maquette de validation métier : `docs/superpowers/sandboxes/2026-06-09-datatable-pagination.html`.
|
||||
|
||||
## Décisions validées
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| Forme | Compact « Page [input] / N » entre Préc. et Suiv. Les numéros et les `…` sont **supprimés**. |
|
||||
| Déclenchement | **Temps réel debounced 400 ms** ; **Entrée** applique immédiatement (court-circuite le debounce). Pendant la frappe, on n'applique que les valeurs dans `[1, N]`. |
|
||||
| Hors limites (Entrée/blur) | **Clamp** : `> N` → page N. Champ vidé / `0` / non numérique → **restaure** la page courante (pas d'émission). |
|
||||
| Saisie | Chiffres uniquement (`inputmode="numeric"`, non-chiffres retirés à la frappe). |
|
||||
| Labels Préc./Suiv. | En français (`Préc.` / `Suiv.`) — posés ici car la branche part de `develop`. |
|
||||
| Contrat | `v-model:page` / `v-model:per-page` inchangé ; `totalPages = ceil(totalItems/perPage)` inchangé. |
|
||||
|
||||
## Conception détaillée
|
||||
|
||||
### 1. Barre de pagination — markup
|
||||
|
||||
**Supprimer** : le computed `visiblePages`, la boucle `v-for` des boutons numérotés, les `…` (`data-test="page-N"`, `aria-hidden` ellipsis).
|
||||
|
||||
**Conserver** : le sélecteur perPage, `Préc.` (`data-test="prev-button"`, `aria-label="Page précédente"`, désactivé si `page <= 1`), `Suiv.` (`data-test="next-button"`, `aria-label="Page suivante"`, désactivé si `page >= totalPages`).
|
||||
|
||||
**Ajouter** entre les deux boutons, dans la `<nav aria-label="Pagination">` :
|
||||
```html
|
||||
<span class="jump flex items-center gap-2 text-sm">
|
||||
<label :for="pageInputId" class="text-m-muted">Page</label>
|
||||
<input
|
||||
:id="pageInputId"
|
||||
v-model="pageInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
aria-label="Aller à la page"
|
||||
data-test="page-input"
|
||||
class="h-[30px] w-[58px] rounded-malio border border-m-border text-center text-sm outline-none focus:border-m-primary"
|
||||
@input="onPageInput"
|
||||
@keydown.enter="commitPageInput"
|
||||
@blur="commitPageInput"
|
||||
>
|
||||
<span class="text-m-muted">/ <span data-test="total-pages">{{ totalPages }}</span></span>
|
||||
</span>
|
||||
```
|
||||
(Classes finales à ajuster au rendu réel ; conserver la hauteur `30px` cohérente avec Préc./Suiv.)
|
||||
|
||||
### 2. État & synchronisation
|
||||
|
||||
- `const pageInput = ref(String(props.page))` — chaîne affichée dans le champ.
|
||||
- `watch(() => props.page, p => { pageInput.value = String(p) })` — resynchronise l'affichage quand la page change (clic Préc./Suiv., changement externe, ou émission debounced confirmée).
|
||||
- Un id stable pour le `for/id` : `const pageInputId = useId()` (ou réutiliser le pattern d'id existant du composant).
|
||||
|
||||
### 3. Comportement de saisie
|
||||
|
||||
Constante interne : `const PAGE_JUMP_DEBOUNCE = 400` ; timer : `let debounceTimer: ReturnType<typeof setTimeout> | null = null` (pattern identique à `InputAutocomplete.vue`).
|
||||
|
||||
**`onPageInput()`** (à chaque frappe) :
|
||||
```ts
|
||||
const onPageInput = () => {
|
||||
// chiffres uniquement
|
||||
pageInput.value = pageInput.value.replace(/[^0-9]/g, '')
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (pageInput.value === '') return // attendre (restauré au blur)
|
||||
const n = Number(pageInput.value)
|
||||
if (n >= 1 && n <= totalPages.value) {
|
||||
debounceTimer = setTimeout(() => changePage(n), PAGE_JUMP_DEBOUNCE)
|
||||
}
|
||||
// hors plage : on n'applique pas en direct (commit au blur/Entrée clampe)
|
||||
}
|
||||
```
|
||||
|
||||
**`commitPageInput()`** (Entrée ou blur) :
|
||||
```ts
|
||||
const commitPageInput = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
const raw = pageInput.value.trim()
|
||||
if (raw === '' || Number(raw) === 0 || Number.isNaN(Number(raw))) {
|
||||
pageInput.value = String(props.page) // restaure la page courante
|
||||
return
|
||||
}
|
||||
const clamped = Math.min(Math.max(1, Math.round(Number(raw))), totalPages.value)
|
||||
changePage(clamped)
|
||||
pageInput.value = String(props.page === clamped ? props.page : clamped)
|
||||
}
|
||||
```
|
||||
|
||||
**`changePage(n)`** (émission, réutilisable) :
|
||||
```ts
|
||||
const changePage = (n: number) => {
|
||||
if (n >= 1 && n <= totalPages.value && n !== props.page) {
|
||||
emit('update:page', n)
|
||||
}
|
||||
}
|
||||
```
|
||||
(Note : le `goToPage` existant — utilisé par Préc./Suiv. — peut être renommé/remplacé par `changePage`, qui a la même garde `1..N`. Préc. appelle `changePage(props.page - 1)`, Suiv. `changePage(props.page + 1)`.)
|
||||
|
||||
**Nettoyage** : `onBeforeUnmount(() => { if (debounceTimer) clearTimeout(debounceTimer) })`.
|
||||
|
||||
### 4. Cas limites (table de comportement, N = 31, page courante = 5)
|
||||
|
||||
| Action | Résultat |
|
||||
|---|---|
|
||||
| Tape `16` d'un trait (< 400 ms) puis pause | 1 émission `update:page(16)` |
|
||||
| Tape `1` puis pause > 400 ms puis `6` | émission `update:page(1)` puis `update:page(16)` (effet « préfixe valide ») |
|
||||
| Tape `50` + Entrée | clamp → `update:page(31)` |
|
||||
| Tape `50` + blur | clamp → `update:page(31)` |
|
||||
| Vide le champ + blur | pas d'émission ; champ réaffiche `5` |
|
||||
| Tape `0` + Entrée | pas d'émission ; champ réaffiche `5` |
|
||||
| Tape `abc` | non-chiffres retirés → champ vide → pas d'émission |
|
||||
| Clic Préc./Suiv. | `update:page(±1)` ; champ synchronisé via `watch` |
|
||||
|
||||
## Tests (`DataTable.test.ts`)
|
||||
|
||||
**Supprimer** les tests devenus caducs (numéros + ellipsis) : `renders all pages when totalPages <= 5`, `highlights current page`, `emits update:page on page button click`, `shows ellipsis…`, `always shows first and last page…`, `shows 1 neighbor on each side…` (DataTable.test.ts:192-250).
|
||||
|
||||
**Conserver** : `hides pagination when totalItems is 0`, `shows pagination when totalItems > 0`, Préc./Suiv. disabled + emits, `pagination nav has aria-label`, prev/next aria-labels.
|
||||
|
||||
**Ajouter** (avec `vi.useFakeTimers()` pour le debounce) :
|
||||
- le champ affiche la page courante et `/ N` (`data-test="page-input"` value, `data-test="total-pages"`).
|
||||
- saisie d'une valeur dans `[1,N]` → après 400 ms (`vi.advanceTimersByTime(400)`) → `update:page(n)`.
|
||||
- saisie puis avance < 400 ms → pas encore d'émission.
|
||||
- Entrée → émission immédiate (sans avancer les timers).
|
||||
- valeur `> N` + Entrée → `update:page(N)` (clamp).
|
||||
- champ vidé + blur → pas d'émission, champ réaffiche la page courante.
|
||||
- `0` + Entrée → pas d'émission.
|
||||
- non-chiffres retirés à la frappe.
|
||||
- changement de `page` (setProps) → le champ se resynchronise.
|
||||
|
||||
## Livrables documentaires
|
||||
|
||||
- `COMPONENTS.md` (section DataTable) : décrire la pagination compacte « Page [n] / N » + le saut de page (debounce 400 ms, Entrée immédiat, clamp).
|
||||
- `CHANGELOG.md` : entrée sous `### Changed`.
|
||||
- Story/playground DataTable : la nouvelle barre est visible via les démos existantes (vérifier qu'un jeu de données > quelques pages est présent ; sinon ajouter un exemple à fort volume).
|
||||
- La maquette `docs/superpowers/sandboxes/2026-06-09-datatable-pagination.html` est committée comme artefact de validation métier.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Configurabilité du délai de debounce via prop (figé à 400 ms ; extensible plus tard si besoin).
|
||||
- Conservation optionnelle des numéros pour les petits volumes (on passe tout en compact, décision métier).
|
||||
- Sélecteur de pages sous forme de `<select>` (écarté au profit du champ).
|
||||
- Toute autre évolution du DataTable (tri, filtres…).
|
||||
@@ -1,117 +0,0 @@
|
||||
# MalioInputAmount — séparateurs de milliers (affichage groupé FR)
|
||||
|
||||
**Date :** 2026-06-09
|
||||
**Statut :** Validé, prêt pour plan d'implémentation
|
||||
**Périmètre :** `MalioInputAmount` uniquement.
|
||||
|
||||
## Objectif
|
||||
|
||||
Afficher les montants avec des **séparateurs de milliers** (espaces) et une **virgule décimale**, à la française : `1 234 567,89`. Demande du client ERP. Le formatage est **purement visuel** ; la valeur émise reste une chaîne numérique propre.
|
||||
|
||||
## Décisions validées
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| Valeur émise (`modelValue`) | Reste **propre** : point décimal, sans espaces (`'1234567.89'`). Contrat consommateur inchangé. Les séparateurs ne sont qu'à l'affichage. |
|
||||
| Moment du formatage | **Temps réel** (à la frappe), avec gestion du curseur. |
|
||||
| Format affiché | Français : **espace** pour les milliers, **virgule** pour les décimales (`1 234 567,89`). |
|
||||
| Activation | **Par défaut sur tous** les `MalioInputAmount` (pas de prop opt-in). |
|
||||
| `maxLength` | S'applique à la **longueur du `modelValue`** (chaîne propre), **pas** à l'affichage. Le `maxlength` natif (qui compterait les espaces) est retiré ; le plafond est appliqué en JS. |
|
||||
| Extraction | Les fonctions pures vont dans `app/components/malio/input/composables/amountFormat.ts` (testables en isolation). |
|
||||
|
||||
## Conception détaillée
|
||||
|
||||
### 1. Contrat de valeur & flux de données
|
||||
|
||||
- **Modèle (émis)** : sortie inchangée de `normalizeAmount` — point décimal, max 2 décimales, zéros de tête retirés, `''` si vide. Ex. `1234567.89`.
|
||||
- **Affichage** : `formatGroupedAmount(model)` groupe la partie entière par 3 avec des espaces et remplace le point par une virgule. Ex. `1 234 567,89`. Si le modèle finit par `.` (décimale en cours, ex. `12.`), l'affichage finit par `,` (`12,`).
|
||||
- **Binding** : l'input affiche `formatGroupedAmount(currentValue)` au lieu de `currentValue`.
|
||||
- **Parse** : à la frappe, le texte saisi (avec espaces/virgule) repasse par `normalizeAmount` → modèle propre → émis.
|
||||
|
||||
### 2. Temps réel & gestion du curseur
|
||||
|
||||
À chaque `@input` :
|
||||
1. Lire `rawText = target.value` et `caret = target.selectionStart`.
|
||||
2. `model = normalizeAmount(rawText)`.
|
||||
3. **Plafond `maxLength`** (cf. §3) : si dépassement, ignorer le keystroke (restaurer l'affichage précédent, ne pas émettre).
|
||||
4. Sinon : `display = formatGroupedAmount(model)` ; écrire `target.value = display` ; **émettre** `update:modelValue(model)`.
|
||||
5. **Repositionner le curseur** : compter les caractères significatifs (tout sauf les espaces de groupement) à gauche du curseur dans `rawText`, puis placer le curseur après ce même nombre de caractères significatifs dans `display`.
|
||||
|
||||
Helpers curseur (purs) :
|
||||
```ts
|
||||
export const countSignificant = (str: string, upTo: number): number =>
|
||||
str.slice(0, upTo).replace(/ /g, '').length
|
||||
|
||||
export const caretFromSignificant = (display: string, sig: number): number => {
|
||||
let seen = 0
|
||||
for (let i = 0; i < display.length; i++) {
|
||||
if (display[i] !== ' ') seen++
|
||||
if (seen >= sig) return i + 1
|
||||
}
|
||||
return display.length
|
||||
}
|
||||
```
|
||||
|
||||
`<input type="text">` supporte l'API de sélection, donc `setSelectionRange` fonctionne directement (pas de try/catch nécessaire).
|
||||
|
||||
### 3. `maxLength` sur le modèle
|
||||
|
||||
- On **retire** le binding `:maxlength="maxLength"` natif de l'`<input>` (il compterait les espaces de l'affichage).
|
||||
- Dans `onInput`, après `model = normalizeAmount(rawText)` : si `props.maxLength != null` **et** `model.length > Number(props.maxLength)`, on **ignore** le keystroke :
|
||||
- on restaure `target.value = formatGroupedAmount(currentValue)` (modèle précédent),
|
||||
- on replace le curseur à `max(0, caret - 1)` (le caractère refusé n'est pas inséré),
|
||||
- on **n'émet pas**.
|
||||
- `maxLength` borne donc la **longueur de la chaîne `modelValue`** (point décimal inclus). Ce point est documenté explicitement.
|
||||
- `minLength` : laissé tel quel (attribut natif de validation). Connu : il s'évalue sur le texte affiché ; hors périmètre de cette évolution.
|
||||
|
||||
### 4. Helpers extraits — `composables/amountFormat.ts`
|
||||
|
||||
- `normalizeAmount(value: string): string` — **déplacé tel quel** depuis le composant (parse).
|
||||
- `formatGroupedAmount(model: string): string` — nouveau (format groupé FR). Algorithme :
|
||||
- Si `model === ''` → `''`.
|
||||
- Séparer sur `.` → `integerPart`, `decimalPart` (présent ssi le modèle contient `.`).
|
||||
- Grouper `integerPart` par paquets de 3 depuis la droite avec une espace `' '`.
|
||||
- Si le modèle contient `.` → `groupedInteger + ',' + decimalPart` (decimalPart éventuellement vide).
|
||||
- Sinon → `groupedInteger`.
|
||||
- `countSignificant`, `caretFromSignificant` — helpers curseur (purs).
|
||||
|
||||
Le composant importe ces helpers ; la logique DOM (lecture `target.value`, `setSelectionRange`) reste dans `InputAmount.vue`.
|
||||
|
||||
### 5. Table de vérité (format/parse)
|
||||
|
||||
| Saisie utilisateur | `modelValue` émis | Affichage (`formatGroupedAmount`) |
|
||||
|---|---|---|
|
||||
| `1234567` | `1234567` | `1 234 567` |
|
||||
| `1234,56` ou `1234.56` | `1234.56` | `1 234,56` |
|
||||
| `12.` (décimale en cours) | `12.` | `12,` |
|
||||
| `,5` | `0.5` | `0,5` |
|
||||
| `0012345abc` | `12345` | `12 345` |
|
||||
| `1234.567` (3 décimales) | `1234.56` | `1 234,56` |
|
||||
| `` (vide) | `` | `` |
|
||||
| `0` | `0` | `0` |
|
||||
|
||||
## Tests
|
||||
|
||||
**`composables/amountFormat.test.ts`** (nouveau) :
|
||||
- `normalizeAmount` : reprise des cas existants (espaces, virgule→point, zéros de tête, 2 décimales max, vide, décimale en tête).
|
||||
- `formatGroupedAmount` : table §5 (groupement par 3, virgule décimale, `12.`→`12,`, vide→vide, nombres < 1000 inchangés).
|
||||
- `countSignificant` / `caretFromSignificant` : positions de curseur clés (avant/après un espace inséré, en fin de chaîne).
|
||||
|
||||
**`InputAmount.test.ts`** (mis à jour) :
|
||||
- Les assertions `input.element.value` passent de la valeur brute (`1234.56`) à la valeur groupée (`1 234,56`).
|
||||
- Les assertions d'émission `update:modelValue` restent **inchangées** (modèle propre : `'1234.56'`, `'0.5'`, `''`…).
|
||||
- Nouveaux tests : groupement à la frappe d'un grand montant (`1234567` → affichage `1 234 567`, émis `1234567`) ; `maxLength` plafonne le modèle (un keystroke au-delà est ignoré, pas d'émission supplémentaire) ; position du curseur après insertion d'un séparateur.
|
||||
|
||||
## Livrables documentaires
|
||||
|
||||
- `COMPONENTS.md` : note dans la section `## MalioInputAmount` — affichage groupé FR (`1 234 567,89`), `modelValue` reste propre (`'1234567.89'`), `maxLength` borne la longueur du modèle.
|
||||
- `CHANGELOG.md` : entrée sous `### Added` / `### Changed`.
|
||||
- Story `app/story/input/inputAmount.story.vue` : exemple grand montant montrant les séparateurs.
|
||||
- Playground `.playground/pages/composant/input/inputAmount.vue` : exemple grand montant + affichage de la valeur ISO/propre émise.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Internationalisation configurable (autres locales / séparateurs paramétrables) — on fige le format FR.
|
||||
- `minLength` sur le modèle (reste natif sur l'affichage).
|
||||
- Passage à `maska` en mode number (approche écartée au profit du `normalizeAmount` existant).
|
||||
- Devises / symboles dynamiques (l'icône € existante est conservée telle quelle).
|
||||
@@ -1,157 +0,0 @@
|
||||
# MalioInputEmail — bouton « + » d'ajout (event `add`)
|
||||
|
||||
**Date :** 2026-06-09
|
||||
**Statut :** Validé, prêt pour plan d'implémentation
|
||||
**Périmètre :** `MalioInputEmail` uniquement.
|
||||
|
||||
## Objectif
|
||||
|
||||
Ajouter à `MalioInputEmail` le même bouton « + » que `MalioInputPhone` : un bouton optionnel à droite du champ qui émet un event `add`, permettant au consommateur d'ajouter dynamiquement un autre champ email (ou toute autre action).
|
||||
|
||||
La logique email existante (ajoutée en MUI-41) est **conservée intégralement** : `sanitizeEmail` (suppression des espaces), prop `lowercase`, gestion du caret dans `onInput`, `type="email"` / `inputmode="email"`. Ce travail n'y touche pas.
|
||||
|
||||
## Décisions validées
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| API | Calquée sur `MalioInputPhone` : props `addable` / `addIconName` / `addButtonLabel`, event `add`, `data-test="add-button"`. |
|
||||
| Collision icône/bouton | L'icône email étant à droite par défaut, quand `addable` est actif l'icône passe **automatiquement à gauche** et le « + » occupe la droite (disposition éprouvée de Phone). |
|
||||
| Libellé par défaut | `addButtonLabel` défaut `'Ajouter une adresse email'`. |
|
||||
| Garde désactivé | Le bouton n'émet pas `add` si `disabled` ou `readonly` (comme Phone). |
|
||||
| Approche | Recopier le pattern de Phone dans Email (pas d'extraction d'un composant partagé — noté comme cleanup futur possible, hors scope). |
|
||||
|
||||
## Conception détaillée
|
||||
|
||||
### 1. Props ajoutées (interface + défauts)
|
||||
|
||||
```ts
|
||||
addable?: boolean // défaut: false
|
||||
addIconName?: string // défaut: 'mdi:plus'
|
||||
addButtonLabel?: string // défaut: 'Ajouter une adresse email'
|
||||
```
|
||||
|
||||
### 2. Event ajouté
|
||||
|
||||
L'`defineEmits` passe de :
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
```
|
||||
à :
|
||||
```ts
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'add'): void
|
||||
}>()
|
||||
```
|
||||
|
||||
### 3. Position d'icône « effective »
|
||||
|
||||
Nouvelle règle, unique source du repositionnement :
|
||||
```ts
|
||||
const effectiveIconPosition = computed(() =>
|
||||
props.addable && props.iconName ? 'left' : props.iconPosition,
|
||||
)
|
||||
```
|
||||
|
||||
Les quatre computeds qui dépendent aujourd'hui de `props.iconPosition` utilisent désormais `effectiveIconPosition.value` :
|
||||
- `iconInputPaddingClass`
|
||||
- `iconPositionClass`
|
||||
- `labelPositionClass`
|
||||
- `focusPaddingClass`
|
||||
|
||||
Conséquence :
|
||||
- `addable=false` → `effectiveIconPosition === props.iconPosition` → comportement **strictement identique** à aujourd'hui.
|
||||
- `addable=true` (avec une icône) → icône à gauche + espace réservé à droite pour le bouton.
|
||||
|
||||
### 4. `iconInputPaddingClass` aligné sur Phone
|
||||
|
||||
Remplacement de l'implémentation actuelle d'Email par la forme éprouvée de Phone (rendu identique dans le cas non-addable car la classe de base `pl-3 pr-3` est commune aux deux composants) :
|
||||
```ts
|
||||
const iconInputPaddingClass = computed(() => {
|
||||
const leftIcon = props.iconName && effectiveIconPosition.value === 'left'
|
||||
const rightIcon = props.iconName && effectiveIconPosition.value === 'right'
|
||||
const parts: string[] = []
|
||||
if (leftIcon) parts.push('!pl-11')
|
||||
if (rightIcon || props.addable) parts.push('!pr-10')
|
||||
return parts.join(' ')
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Bouton dans le template
|
||||
|
||||
Inséré après le bloc `<IconifyIcon v-if="iconName">`, à l'identique de Phone :
|
||||
```html
|
||||
<button
|
||||
v-if="addable"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="addButtonLabel"
|
||||
data-test="add-button"
|
||||
:class="mergedAddButtonClass"
|
||||
@click="onAdd"
|
||||
>
|
||||
<IconifyIcon
|
||||
:icon="addIconName"
|
||||
:width="24"
|
||||
:height="24"
|
||||
data-test="add-icon"
|
||||
/>
|
||||
</button>
|
||||
```
|
||||
|
||||
### 6. `mergedAddButtonClass` (copie de Phone)
|
||||
|
||||
```ts
|
||||
const mergedAddButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'absolute right-[10px] top-1/2 -translate-y-1/2 cursor-pointer transition-opacity hover:opacity-70',
|
||||
iconStateClass.value,
|
||||
props.disabled ? 'cursor-not-allowed opacity-40 hover:opacity-40' : '',
|
||||
),
|
||||
)
|
||||
```
|
||||
(`iconStateClass` existe déjà dans Email.)
|
||||
|
||||
### 7. Handler `onAdd` (copie de Phone)
|
||||
|
||||
```ts
|
||||
const onAdd = () => {
|
||||
if (props.disabled || props.readonly) return
|
||||
emit('add')
|
||||
}
|
||||
```
|
||||
|
||||
## Quatre computeds à modifier — détail
|
||||
|
||||
Aujourd'hui dans `InputEmail.vue` ils référencent `props.iconPosition` ; ils doivent référencer `effectiveIconPosition.value`. Les corps restent identiques par ailleurs :
|
||||
- `iconPositionClass` : `effectiveIconPosition.value === 'left' ? 'left-[10px]' : 'right-[10px]'`
|
||||
- `labelPositionClass` : `props.iconName && effectiveIconPosition.value === 'left' ? 'left-11' : 'left-3'`
|
||||
- `focusPaddingClass` : `props.iconName && effectiveIconPosition.value === 'left' ? 'focus:!pl-11' : 'focus:pl-[11px]'`
|
||||
- `iconInputPaddingClass` : remplacé par la version §4.
|
||||
|
||||
## Tests (`InputEmail.test.ts`)
|
||||
|
||||
Ajouts (mirroring `InputPhone.test.ts`), sans toucher aux tests existants de sanitisation/lowercase :
|
||||
- `addable=false` (défaut) → pas de `[data-test="add-button"]`.
|
||||
- `addable=true` → `[data-test="add-button"]` présent.
|
||||
- Clic sur le bouton → un event `add` émis (longueur 1).
|
||||
- `addable + disabled` → clic n'émet pas `add` ; le bouton a l'attribut `disabled`.
|
||||
- `addable + readonly` → clic n'émet pas `add` ; le bouton n'a PAS l'attribut natif `disabled` (la garde `onAdd` bloque).
|
||||
- `addable=true` avec icône → l'icône email est positionnée à gauche (`left-[10px]` présent / `right-[10px]` absent sur l'icône `[data-test="icon"]`).
|
||||
- Non-régression : avec `addable=false`, l'icône reste à droite (`right-[10px]`).
|
||||
- `addButtonLabel` personnalisé → `aria-label` respecté ; défaut → `'Ajouter une adresse email'`.
|
||||
|
||||
## Livrables documentaires
|
||||
|
||||
- `COMPONENTS.md` : ajouter les lignes `addable` / `addIconName` / `addButtonLabel` et l'event `add()` dans la section `## MalioInputEmail`, plus un exemple `<MalioInputEmail ... addable @add="..." />`.
|
||||
- `CHANGELOG.md` : entrée sous `### Added`.
|
||||
- Story `app/story/input/inputEmail.story.vue` : une carte « Addable » avec `@add` (calquée sur `inputPhone.story.vue`).
|
||||
- Playground `.playground/pages/composant/input/inputEmail.vue` : un exemple `addable` avec un handler qui illustre l'ajout d'un champ.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Extraction d'un composant/bouton partagé entre Phone et Email (refactor dédié futur).
|
||||
- Gestion réelle de la liste de champs email côté composant (c'est au consommateur de réagir à l'event `add`, comme pour Phone).
|
||||
- Toute modification de la logique de sanitisation / `lowercase` / caret existante.
|
||||
@@ -1,118 +0,0 @@
|
||||
# MalioDate — saisie manuelle au clavier
|
||||
|
||||
**Date :** 2026-06-09
|
||||
**Statut :** Validé, prêt pour plan d'implémentation
|
||||
**Périmètre :** `MalioDate` uniquement (la famille `DateTime`/`DateRange`/`DateWeek` n'est pas concernée pour l'instant).
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre à l'utilisateur de saisir une date au clavier (`JJ/MM/AAAA`) dans `MalioDate`, en plus de la sélection via le calendrier. Aujourd'hui l'`<input>` de `CalendarField` est codé en dur en `readonly` : seule la sélection au calendrier est possible.
|
||||
|
||||
## Décisions d'UX (validées)
|
||||
|
||||
| Sujet | Décision |
|
||||
|-------|----------|
|
||||
| Ouverture du popover | Le **focus** (ou le clic) ouvre le calendrier, tout en laissant taper en même temps. |
|
||||
| Masque / validation | Masque `maska` `##/##/####` pendant la frappe ; **validation au blur** (pas à chaque touche). |
|
||||
| Activation | **Opt-in** via une prop `editable` (défaut `false`). Aucune régression pour les consommateurs existants. |
|
||||
| Saisie invalide au blur | On **garde le texte tapé** et on affiche un **état d'erreur visuel** (bordure rouge + message). |
|
||||
| Message d'erreur par défaut | « **Date invalide** » (couvre aussi le hors-bornes min/max), surchargeable via prop. |
|
||||
| Touche Entrée | Déclenche le `commit` (parse immédiat) + ferme le popover. |
|
||||
|
||||
## Approche retenue
|
||||
|
||||
**Mode `editable` dans `CalendarField`, parsing dans `MalioDate`.**
|
||||
|
||||
`CalendarField` reste agnostique au format : il expose un mode éditable (input non `readonly`, masque, buffer local) et émet du texte brut. `MalioDate` conserve toute la logique propre à la date (parse, validation `min`/`max`, état d'erreur). Cela évite de coupler `CalendarField` à un format date spécifique et garde le terrain prêt pour une éventuelle extension future à la famille Date.
|
||||
|
||||
Approches écartées :
|
||||
- **`CalendarField` générique avec fonction `parse` injectée** : trop générique pour le périmètre actuel (YAGNI).
|
||||
- **`MalioDate` gère son propre `<input>`** : duplication du rendu / label flottant / styles de `CalendarField`.
|
||||
|
||||
## Conception détaillée
|
||||
|
||||
### 1. `MalioDate` — props ajoutées
|
||||
|
||||
- `editable?: boolean` — défaut `false`. Active la saisie clavier.
|
||||
- `invalidMessage?: string` — défaut `'Date invalide'`. Message affiché en cas de saisie invalide/hors-bornes.
|
||||
|
||||
Quand `editable === false`, le comportement est **strictement identique** à aujourd'hui (lecture seule, sélection calendrier uniquement).
|
||||
|
||||
### 2. `CalendarField` — mode éditable
|
||||
|
||||
Ajout d'une prop `editable?: boolean` (défaut `false`). Quand `true` :
|
||||
|
||||
- L'`<input>` perd l'attribut `readonly` et reçoit `v-maska="'##/##/####'"`.
|
||||
- Un buffer local `draft` (ref) alimente l'input : `:value="editable ? draft : displayValue"`.
|
||||
- `draft` est **resynchronisé** sur `displayValue` via un `watch` → couvre la sélection au calendrier, le clear, et tout changement externe de `modelValue`. Cette resynchro **efface aussi l'état d'erreur** côté `MalioDate` (via le nouveau `displayValue` émis).
|
||||
- À la frappe (`@input`) : met à jour `draft` et émet `input(text)`. **Pas de validation** à ce stade.
|
||||
- Au blur (`@blur`) : émet `commit(text)`.
|
||||
- À la touche Entrée (`@keydown.enter`) : émet `commit(text)` + ferme le popover.
|
||||
- `@focus` ouvre le popover, tout en laissant taper (input non `readonly`).
|
||||
|
||||
Quand `editable === false`, aucun de ces comportements ne s'applique : le chemin de code actuel reste inchangé.
|
||||
|
||||
`disabled` et `readonly` priment toujours sur `editable` (champ non éditable).
|
||||
|
||||
### 3. `MalioDate` — parsing, validation, état d'erreur
|
||||
|
||||
Une ref locale `internalError` est fusionnée avec la prop `error` du consommateur et transmise à `CalendarField` :
|
||||
`:error="error || internalError"` (l'erreur métier du consommateur reste prioritaire).
|
||||
|
||||
Sur réception de `commit(text)` :
|
||||
|
||||
- **Texte vide** → `emit('update:modelValue', null)` ; `internalError = ''`.
|
||||
- **Valide** (`parseDisplayToIso(text)` non `null` **et** `isDateInRange(iso, min, max)`) → `emit('update:modelValue', iso)` ; `internalError = ''`.
|
||||
- **Invalide ou hors-bornes** → on **n'émet pas** de nouveau `modelValue` ; `internalError = props.invalidMessage`. Le texte tapé reste affiché.
|
||||
|
||||
L'état d'erreur s'efface dès qu'une saisie valide ou une sélection calendrier ultérieure produit un nouveau `displayValue`.
|
||||
|
||||
## Flux de données
|
||||
|
||||
```
|
||||
Frappe clavier
|
||||
└─ CalendarField: maj draft + émet input(text) (pas de validation)
|
||||
Blur / Entrée
|
||||
└─ CalendarField: émet commit(text)
|
||||
└─ MalioDate: parseDisplayToIso + isDateInRange
|
||||
├─ valide → emit update:modelValue(iso) ; internalError=''
|
||||
├─ vide → emit update:modelValue(null) ; internalError=''
|
||||
└─ invalide→ internalError = invalidMessage ; (texte conservé)
|
||||
|
||||
Sélection calendrier
|
||||
└─ emit update:modelValue(iso)
|
||||
└─ displayValue change → CalendarField resync draft → erreur effacée
|
||||
```
|
||||
|
||||
## Réutilisation de l'existant
|
||||
|
||||
Les helpers nécessaires existent déjà dans `app/components/malio/date/composables/dateFormat.ts` :
|
||||
- `parseDisplayToIso(display)` → `string | null`
|
||||
- `isValidIso(iso)` → `boolean`
|
||||
- `isDateInRange(iso, min?, max?)` → `boolean`
|
||||
- `formatIsoToDisplay(iso)` → `string`
|
||||
|
||||
`maska` est déjà une dépendance du projet (utilisée par `InputText`/`InputPhone` via `v-maska` + `vMaska` de `maska/vue`).
|
||||
|
||||
## Tests (`Date.test.ts`)
|
||||
|
||||
- Frappe valide + blur → émet l'ISO attendu.
|
||||
- Saisie invalide (`32/13/2026`) au blur → texte conservé, message « Date invalide », `aria-invalid`.
|
||||
- Date valide hors `min`/`max` au blur → état d'erreur.
|
||||
- Saisie vide au blur → émet `null`.
|
||||
- Sélection au calendrier après une saisie invalide → erreur effacée, valeur mise à jour.
|
||||
- Touche Entrée → commit + fermeture popover.
|
||||
- `editable=false` (défaut) → input reste `readonly`, aucun nouveau comportement (non-régression).
|
||||
- `invalidMessage` personnalisé → message affiché respecté.
|
||||
|
||||
## Livrables documentaires
|
||||
|
||||
- Mise à jour de `COMPONENTS.md` (props `editable`, `invalidMessage`).
|
||||
- Entrée dans `CHANGELOG.md`.
|
||||
- Mise à jour de la story Histoire + page playground de `MalioDate` pour exposer la prop `editable`.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Extension de la saisie manuelle à `DateTime`, `DateRange`, `DateWeek`.
|
||||
- Saisie partielle « intelligente » (auto-complétion d'année, etc.).
|
||||
- Validation à la frappe (on reste sur validation au blur).
|
||||
Reference in New Issue
Block a user