7.7 KiB
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"> :
<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) :
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) :
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) :
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.htmlest 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…).