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

17 KiB
Raw Blame History

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 :

import { computed, useAttrs, useId } from 'vue'

par :

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 :

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 :

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. :

        <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 :

        <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 :

        <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. :

        <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 :

        <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
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 :

import {describe, expect, it} from 'vitest'

par :

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 :

  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
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) :

**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 :

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

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