Files
malio-layer-ui/docs/superpowers/specs/2026-06-09-datatable-pagination-goto-design.md
T

7.7 KiB
Raw Blame History

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.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…).