17 KiB
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
goToPageetvisiblePagesparchangePage+ 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
16d'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.
- Taper
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:pageinchangé ; 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).