docs(datatable) : spec + sandbox pagination aller-à-la-page

This commit is contained in:
2026-06-09 15:02:05 +02:00
parent bd9a204988
commit ccd84d6d4a
2 changed files with 376 additions and 0 deletions
@@ -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…).