Compare commits

..

1 Commits

Author SHA1 Message Date
Matthieu 194090b469 fix(input) : lisibilité des blocs de code dans InputRichText
Le merge #63 (squash) a écrasé le commit fix(input) sous un message
non conventionnel, donc semantic-release n'a rien publié. Commit vide
pour déclencher la release du correctif déjà présent dans le code.
2026-06-08 14:57:44 +02:00
12 changed files with 107 additions and 968 deletions
@@ -47,20 +47,6 @@ const paginatedItems = computed(() => {
function onRowClick(item: Record<string, unknown>) { function onRowClick(item: Record<string, unknown>) {
alert(`Clic sur ${item.nom} ${item.prenom} (id: ${item.id})`) alert(`Clic sur ${item.nom} ${item.prenom} (id: ${item.id})`)
} }
const bigPage = ref(1)
const bigPerPage = ref(10)
const bigItems = Array.from({ length: 310 }, (_, i) => ({
id: i + 1,
nom: `Nom ${i + 1}`,
prenom: `Prénom ${i + 1}`,
ville: ['Paris', 'Lyon', 'Marseille'][i % 3],
montant: 500 + i * 7,
}))
const bigPaginated = computed(() => {
const start = (bigPage.value - 1) * bigPerPage.value
return bigItems.slice(start, start + bigPerPage.value)
})
</script> </script>
<template> <template>
@@ -102,21 +88,5 @@ const bigPaginated = computed(() => {
</template> </template>
</MalioDataTable> </MalioDataTable>
</div> </div>
<div class="rounded-lg border p-6">
<h2 class="mb-6 text-xl font-bold">Gros volume (31 pages) saut de page</h2>
<MalioDataTable
:columns="columns"
:items="bigPaginated"
:total-items="bigItems.length"
v-model:page="bigPage"
v-model:per-page="bigPerPage"
>
<template #cell-montant="{ item }">
<strong>{{ item.montant }} </strong>
</template>
</MalioDataTable>
<p class="mt-3 text-sm text-gray-500">Page courante : {{ bigPage }} / {{ Math.ceil(bigItems.length / bigPerPage) }}</p>
</div>
</div> </div>
</template> </template>
-4
View File
@@ -43,10 +43,6 @@ Liste des évolutions de la librairie Malio layer UI
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`) * [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
### Changed ### Changed
* 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.`.
* MalioButton : dimensions par défaut `w-[180px]` / `h-[38px]` (étaient `w-[200px]` / `h-[40px]`).
* DataTable : tailles par défaut revues — texte header `16px` (était `20px`), texte body `14px` (était `18px`), sélecteur de lignes et boutons de pagination (Prev / numéros / Next) alignés à `30px` de haut, padding de `12px` entre le bas du tableau et la barre de pagination, texte header et body passés en noir (`text-black`, étaient `text-m-primary`).
* Select : nouvelle prop `fieldClass` pour surcharger les classes du field (notamment la hauteur `h-[40px]` jusqu'ici codée en dur) ; utilisée par le DataTable pour passer le sélecteur de perPage à `30px`.
* [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`. * [#MUI-35] Refonte du composant drawer : slots `#header`/`#footer`, prop `side` (droite/gauche), `dismissable`, `closeOnEscape`, classes d'override, focus-trap, scroll-lock et fermeture au clavier. **Breaking** : la prop `title` est remplacée par le slot `#header`.
### Fixed ### Fixed
-3
View File
@@ -388,7 +388,6 @@ Liste déroulante.
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton | | `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur | | `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
| `textLabel` | `string` | `'text-sm'` | Classe taille texte label | | `textLabel` | `string` | `'text-sm'` | Classe taille texte label |
| `fieldClass` | `string` | `''` | Classes supplémentaires sur le field (override hauteur, ex. `h-[30px]`) |
| `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide | | `noOptionsText` | `string` | `'Aucune option disponible'` | Message affiché dans la dropdown quand `options` est vide |
**Events :** `update:modelValue(value: string | number | null)` **Events :** `update:modelValue(value: string | number | null)`
@@ -957,8 +956,6 @@ Boîte de dialogue modale centrée avec backdrop semi-transparent. Gère l'acces
Tableau de données presentational avec pagination, filtres par slots et lignes cliquables. Tableau de données presentational avec pagination, filtres par slots et lignes cliquables.
**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é.
| Prop | Type | Défaut | Description | | Prop | Type | Défaut | Description |
|------|------|--------|-------------| |------|------|--------|-------------|
| `id` | `string` | auto | Identifiant HTML | | `id` | `string` | auto | Identifiant HTML |
+2 -2
View File
@@ -162,8 +162,8 @@ describe('MalioButton', () => {
it('applies correct dimensions', () => { it('applies correct dimensions', () => {
const wrapper = mountComponent() const wrapper = mountComponent()
expect(wrapper.get('button').classes()).toContain('w-[180px]') expect(wrapper.get('button').classes()).toContain('w-[200px]')
expect(wrapper.get('button').classes()).toContain('h-[38px]') expect(wrapper.get('button').classes()).toContain('h-[40px]')
}) })
it('applies font styles', () => { it('applies font styles', () => {
+1 -1
View File
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
const mergedButtonClass = computed(() => const mergedButtonClass = computed(() =>
twMerge( twMerge(
'inline-flex w-[180px] h-[38px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50', 'inline-flex w-[200px] h-[40px] items-center justify-center gap-1 p-[10px] rounded-md text-base font-bold leading-[150%] transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-m-primary/50',
variantClasses.value, variantClasses.value,
props.buttonClass, props.buttonClass,
), ),
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { describe, expect, it } from 'vitest'
import { h } from 'vue' import { h } from 'vue'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import type { DefineComponent } from 'vue' import type { DefineComponent } from 'vue'
@@ -189,6 +189,24 @@ describe('MalioDataTable', () => {
expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true) expect(wrapper.find('[data-test="pagination"]').exists()).toBe(true)
}) })
it('renders all pages when totalPages <= 5', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10 })
for (let i = 1; i <= 5; i++) {
expect(wrapper.find(`[data-test="page-${i}"]`).exists()).toBe(true)
}
})
it('highlights current page', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 3 })
expect(wrapper.find('[data-test="page-3"]').attributes('aria-current')).toBe('page')
})
it('emits update:page on page button click', async () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
await wrapper.find('[data-test="page-3"]').trigger('click')
expect(wrapper.emitted('update:page')?.[0]).toEqual([3])
})
it('Prev button is disabled on page 1', () => { it('Prev button is disabled on page 1', () => {
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 }) const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined() expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
@@ -211,6 +229,26 @@ describe('MalioDataTable', () => {
expect(wrapper.emitted('update:page')?.[0]).toEqual([4]) expect(wrapper.emitted('update:page')?.[0]).toEqual([4])
}) })
it('shows ellipsis for truncated pages (> 5 pages)', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
const ellipsis = wrapper.findAll('[aria-hidden="true"]')
expect(ellipsis.length).toBeGreaterThan(0)
expect(ellipsis[0].text()).toBe('…')
})
it('always shows first and last page when > 5 pages', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
expect(wrapper.find('[data-test="page-1"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-20"]').exists()).toBe(true)
})
it('shows 1 neighbor on each side of current page', () => {
const wrapper = mountComponent({ totalItems: 200, perPage: 10, page: 10 })
expect(wrapper.find('[data-test="page-9"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-10"]').exists()).toBe(true)
expect(wrapper.find('[data-test="page-11"]').exists()).toBe(true)
})
it('pagination nav has aria-label', () => { it('pagination nav has aria-label', () => {
const wrapper = mountComponent({ totalItems: 30 }) const wrapper = mountComponent({ totalItems: 30 })
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination') expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
@@ -227,80 +265,6 @@ describe('MalioDataTable', () => {
}) })
}) })
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')
})
})
describe('Per-page selector', () => { describe('Per-page selector', () => {
it('emits update:per-page and reset page to 1 on change', async () => { it('emits update:per-page and reset page to 1 on change', async () => {
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 }) const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
+64 -60
View File
@@ -7,14 +7,14 @@
v-for="col in columns" v-for="col in columns"
:key="col.key" :key="col.key"
scope="col" scope="col"
class="border-b border-black px-3 py-3 text-left align-middle text-[16px]" class="border-b border-black px-3 py-3 text-left align-middle text-[20px]"
> >
<slot <slot
v-if="$slots[`header-${col.key}`]" v-if="$slots[`header-${col.key}`]"
:name="`header-${col.key}`" :name="`header-${col.key}`"
:column="col" :column="col"
/> />
<span v-else class="font-semibold text-black">{{ col.label }}</span> <span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -32,7 +32,7 @@
<td <td
v-for="col in columns" v-for="col in columns"
:key="col.key" :key="col.key"
class="px-3 py-4 text-[14px] text-black" class="px-3 py-4 text-[18px] text-m-primary"
:class="index < items.length - 1 ? 'border-b border-black' : ''" :class="index < items.length - 1 ? 'border-b border-black' : ''"
> >
<slot <slot
@@ -57,17 +57,16 @@
<div <div
v-if="totalItems > 0" v-if="totalItems > 0"
class="flex items-center justify-between pt-3" class="flex items-center justify-between pt-2"
data-test="pagination" data-test="pagination"
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span> <span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
<div class="h-[30px]"> <div class="h-12">
<MalioSelect <MalioSelect
:model-value="perPage" :model-value="perPage"
:options="perPageSelectOptions" :options="perPageSelectOptions"
group-class="w-20 h-[30px]" group-class="w-20"
field-class="h-[30px]"
rounded="rounded" rounded="rounded"
text-field="text-sm" text-field="text-sm"
text-value="text-sm" text-value="text-sm"
@@ -81,39 +80,43 @@
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav"> <nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
label="Préc." label="Prev"
:disabled="page <= 1" :disabled="page <= 1"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm" button-class="h-10 w-auto min-w-0 px-3 text-sm"
aria-label="Page précédente" aria-label="Page précédente"
data-test="prev-button" data-test="prev-button"
@click="changePage(page - 1)" @click="goToPage(page - 1)"
/> />
<span class="flex items-center gap-2 text-sm"> <template v-for="(p, idx) in visiblePages" :key="idx">
<label :for="pageInputId" class="text-m-muted">Page</label> <span
<input v-if="p === '...'"
:id="pageInputId" class="px-1 text-sm text-m-muted"
v-model="pageInput" aria-hidden="true"
type="text" ></span>
inputmode="numeric" <button
aria-label="Aller à la page" v-else
data-test="page-input" type="button"
class="h-[30px] w-[58px] rounded-malio border border-m-border text-center text-sm text-m-text outline-none focus:border-m-primary" class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
@input="onPageInput" :class="p === page
@keydown.enter="commitPageInput" ? 'bg-m-btn-primary text-white font-semibold'
@blur="commitPageInput" : 'text-m-text hover:bg-m-bg'"
:aria-current="p === page ? 'page' : undefined"
:data-test="`page-${p}`"
@click="goToPage(p)"
> >
<span class="text-m-muted">/ <span data-test="total-pages">{{ totalPages }}</span></span> {{ p }}
</span> </button>
</template>
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
label="Suiv." label="Next"
:disabled="page >= totalPages" :disabled="page >= totalPages"
button-class="h-[30px] w-auto min-w-0 px-3 text-sm" button-class="h-10 w-auto min-w-0 px-3 text-sm"
aria-label="Page suivante" aria-label="Page suivante"
data-test="next-button" data-test="next-button"
@click="changePage(page + 1)" @click="goToPage(page + 1)"
/> />
</nav> </nav>
</div> </div>
@@ -121,7 +124,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount, useAttrs, useId } from 'vue' import { computed, useAttrs, useId } from 'vue'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import MalioSelect from '../select/Select.vue' import MalioSelect from '../select/Select.vue'
import MalioButton from '../button/Button.vue' import MalioButton from '../button/Button.vue'
@@ -169,15 +172,6 @@ const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage))) const totalPages = computed(() => Math.max(1, Math.ceil(props.totalItems / props.perPage)))
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) })
const perPageSelectOptions = computed(() => const perPageSelectOptions = computed(() =>
props.perPageOptions.map(n => ({ label: String(n), value: n })) props.perPageOptions.map(n => ({ label: String(n), value: n }))
) )
@@ -189,32 +183,42 @@ function onPerPageChange(value: string | number | null) {
} }
} }
function changePage(page: number) { function goToPage(page: number) {
if (page >= 1 && page <= totalPages.value && page !== props.page) { if (page >= 1 && page <= totalPages.value) {
emit('update:page', page) emit('update:page', page)
} }
} }
function onPageInput() { const visiblePages = computed(() => {
pageInput.value = pageInput.value.replace(/[^0-9]/g, '') const total = totalPages.value
if (debounceTimer) clearTimeout(debounceTimer) const current = props.page
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 (total <= 5) {
if (debounceTimer) clearTimeout(debounceTimer) return Array.from({ length: total }, (_, i) => i + 1)
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) const pages: (number | '...')[] = []
pageInput.value = String(clamped) pages.push(1)
}
if (current > 3) {
pages.push('...')
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < total - 2) {
pages.push('...')
}
if (total > 1) {
pages.push(total)
}
return pages
})
</script> </script>
+1 -3
View File
@@ -34,7 +34,7 @@
? 'border-black' ? 'border-black'
: 'border-m-muted', : 'border-m-muted',
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer', disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass), label ? 'min-h-[40px]' : 'h-[40px] py-0',
rounded, rounded,
textField, textField,
]" ]"
@@ -206,7 +206,6 @@ const props = withDefaults(defineProps<{
textField?: string textField?: string
textValue?: string textValue?: string
textLabel?: string textLabel?: string
fieldClass?: string
rounded?: string rounded?: string
disabled?: boolean disabled?: boolean
readonly?: boolean readonly?: boolean
@@ -224,7 +223,6 @@ const props = withDefaults(defineProps<{
textField: 'text-lg', textField: 'text-lg',
textValue: 'text-lg', textValue: 'text-lg',
textLabel: 'text-sm', textLabel: 'text-sm',
fieldClass: '',
rounded: 'rounded-md', rounded: 'rounded-md',
disabled: false, disabled: false,
readonly: false, readonly: false,
-30
View File
@@ -49,22 +49,6 @@
</div> </div>
</Variant> </Variant>
<Variant title="Gros volume (saut de page)">
<div class="p-4">
<MalioDataTable
:columns="columns"
:items="bigPaginated"
:total-items="bigItems.length"
v-model:page="bigPage"
v-model:per-page="bigPerPage"
>
<template #cell-montant="{ item }">
<strong>{{ item.montant }} </strong>
</template>
</MalioDataTable>
</div>
</Variant>
<Variant title="État vide"> <Variant title="État vide">
<div class="p-4"> <div class="p-4">
<MalioDataTable <MalioDataTable
@@ -208,18 +192,4 @@ const paginatedItems = computed(() => {
function onRowClick(item: Record<string, unknown>) { function onRowClick(item: Record<string, unknown>) {
alert(`Clic sur ${item.nom} ${item.prenom}`) alert(`Clic sur ${item.nom} ${item.prenom}`)
} }
const bigPage = ref(1)
const bigPerPage = ref(10)
const bigItems = Array.from({ length: 310 }, (_, i) => ({
id: i + 1,
nom: `Nom ${i + 1}`,
prenom: `Prénom ${i + 1}`,
ville: ['Paris', 'Lyon', 'Marseille'][i % 3],
montant: 500 + i * 7,
}))
const bigPaginated = computed(() => {
const start = (bigPage.value - 1) * bigPerPage.value
return bigItems.slice(start, start + bigPerPage.value)
})
</script> </script>
@@ -1,384 +0,0 @@
# 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 :
```ts
import { computed, useAttrs, useId } from 'vue'
```
par :
```ts
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 :
```ts
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 :
```ts
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. :
```html
<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 :
```html
<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 :
```html
<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. :
```html
<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 :
```html
<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**
```bash
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 :
```ts
import {describe, expect, it} from 'vitest'
```
par :
```ts
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 :
```ts
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**
```bash
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) :
```markdown
**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 :
```markdown
* 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**
```bash
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).
```bash
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).
@@ -1,228 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sandbox — Pagination DataTable (proposition)</title>
<style>
:root{
--m-primary:#222783; --m-primary-hover:#121cdb; --m-primary-light:#efeffd;
--m-bg:#f3f4f8; --m-text:#0f172a; --m-muted:#64748b; --m-border:#cbd5e1; --m-radius:6px;
}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:var(--m-text);background:var(--m-bg);line-height:1.5}
.wrap{max-width:920px;margin:0 auto;padding:32px 20px 64px}
h1{font-size:22px;margin:0 0 4px}
.sub{color:var(--m-muted);margin:0 0 28px}
.card{background:#fff;border:1px solid var(--m-border);border-radius:10px;padding:20px 22px;margin-bottom:22px}
.card h2{font-size:15px;margin:0 0 14px;letter-spacing:.01em}
.muted{color:var(--m-muted)}
.small{font-size:13px}
code{background:var(--m-primary-light);color:var(--m-primary);padding:1px 6px;border-radius:4px;font-size:13px}
/* ----- pagination bar (proposition) ----- */
.pagination{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.btn{
height:30px;padding:0 12px;font-size:14px;border-radius:var(--m-radius);
border:1px solid var(--m-border);background:#fff;color:var(--m-text);cursor:pointer;
display:inline-flex;align-items:center;transition:background .12s,border-color .12s,color .12s;
}
.btn:hover:not(:disabled){border-color:var(--m-primary);color:var(--m-primary)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.jump{display:inline-flex;align-items:center;gap:8px;font-size:14px}
.jump label{color:var(--m-muted)}
.jump input{
width:58px;height:30px;text-align:center;font-size:14px;border:1px solid var(--m-border);
border-radius:var(--m-radius);outline:none;color:var(--m-text);
}
.jump input:focus{border-color:var(--m-primary);box-shadow:0 0 0 2px var(--m-primary-light)}
.jump .total{color:var(--m-muted)}
.perpage{display:inline-flex;align-items:center;gap:8px;font-size:14px;color:var(--m-muted)}
.perpage select{height:30px;border:1px solid var(--m-border);border-radius:var(--m-radius);padding:0 8px;color:var(--m-text)}
/* ----- "avant" (état actuel) ----- */
.old{display:flex;align-items:center;gap:6px;opacity:.7;flex-wrap:wrap}
.old .pg{height:30px;min-width:38px;padding:0 8px;display:inline-flex;align-items:center;justify-content:center;border-radius:6px;font-size:14px;border:1px solid transparent}
.old .pg.cur{background:var(--m-primary);color:#fff;font-weight:600}
.old .pg.btn-like{border:1px solid var(--m-border)}
.old .dots{color:var(--m-muted);padding:0 2px}
.controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-bottom:6px}
.controls label{font-size:13px;color:var(--m-muted);display:inline-flex;gap:6px;align-items:center}
.controls input,.controls select{height:28px;border:1px solid var(--m-border);border-radius:6px;padding:0 8px}
.log{margin-top:14px;border-top:1px dashed var(--m-border);padding-top:12px}
.log h3{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--m-muted);margin:0 0 8px}
.log ul{list-style:none;margin:0;padding:0;max-height:150px;overflow:auto;font-size:13px}
.log li{padding:3px 0;border-bottom:1px solid #f1f5f9;display:flex;justify-content:space-between;gap:12px}
.log li .t{color:var(--m-muted);font-variant-numeric:tabular-nums}
.badge{display:inline-block;background:var(--m-primary-light);color:var(--m-primary);font-size:12px;padding:2px 8px;border-radius:999px;margin-left:6px}
ul.notes{margin:8px 0 0;padding-left:18px}
ul.notes li{margin:3px 0;font-size:13px;color:var(--m-muted)}
</style>
</head>
<body>
<div class="wrap">
<h1>Pagination DataTable — proposition « aller à la page »</h1>
<p class="sub">Maquette interactive pour validation métier. Aucun code définitif — sert à valider le comportement avant développement.</p>
<div class="card">
<h2>Avant — état actuel <span class="badge">existant</span></h2>
<div class="old" id="old-bar"></div>
<ul class="notes">
<li>Boutons Préc. / numéros / « … » / Suiv. Pour aller loin (ex. page 16 sur 31), il faut cliquer plusieurs fois ou viser un numéro.</li>
</ul>
</div>
<div class="card">
<h2>Après — proposition <span class="badge">nouveau</span></h2>
<div class="controls">
<label>Nombre de pages
<input id="cfg-pages" type="number" min="1" value="31" style="width:70px">
</label>
<label>Délai debounce
<select id="cfg-delay">
<option value="300">300 ms</option>
<option value="400" selected>400 ms</option>
<option value="600">600 ms</option>
</select>
</label>
</div>
<div class="pagination">
<span class="perpage">
Lignes :
<select disabled><option>25</option></select>
</span>
<button class="btn" id="prev"> Préc.</button>
<span class="jump">
<label for="page-input">Page</label>
<input id="page-input" type="text" inputmode="numeric" value="1" aria-label="Aller à la page">
<span class="total">/ <span id="total">31</span></span>
</span>
<button class="btn" id="next">Suiv. </button>
</div>
<ul class="notes">
<li>Taper un numéro l'applique après <strong id="delay-label">400&nbsp;ms</strong> (debounce) — seules les valeurs valides <code>1..N</code> partent en cours de frappe.</li>
<li><strong>Entrée</strong> applique immédiatement (court-circuite le debounce).</li>
<li>Valeur &gt; N → on va à la dernière page (clamp). Champ vidé / 0 → on restaure la page courante.</li>
</ul>
<div class="log">
<h3>Journal des « chargements de données » (1 ligne = 1 appel serveur simulé)</h3>
<ul id="log"></ul>
</div>
</div>
<p class="small muted">Astuce démo : tape <code>16</code> d'un trait → un seul chargement (page 16). Tape lentement <code>3</code><code>1</code> → tu verras un chargement intermédiaire page 3, puis page 31 : c'est l'effet « préfixe valide » expliqué au métier.</p>
</div>
<script>
(function(){
var pages = 31, page = 1, delay = 400;
var timer = null;
var input = document.getElementById('page-input');
var totalEl = document.getElementById('total');
var prev = document.getElementById('prev');
var next = document.getElementById('next');
var logEl = document.getElementById('log');
var cfgPages = document.getElementById('cfg-pages');
var cfgDelay = document.getElementById('cfg-delay');
var delayLabel = document.getElementById('delay-label');
function now(){
var d = new Date();
return ('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)+':'+('0'+d.getSeconds()).slice(-2)+'.'+('00'+d.getMilliseconds()).slice(-3);
}
function loadData(p){
var li = document.createElement('li');
li.innerHTML = '<span>Chargement page <strong>'+p+'</strong></span><span class="t">'+now()+'</span>';
logEl.insertBefore(li, logEl.firstChild);
}
function render(){
totalEl.textContent = pages;
input.value = page;
prev.disabled = page <= 1;
next.disabled = page >= pages;
renderOld();
}
// commit a page change (clamped), simulate server load if it actually changes
function goTo(p, opts){
opts = opts || {};
if (isNaN(p)) { input.value = page; return; } // not a number → restore
p = Math.min(Math.max(1, Math.round(p)), pages); // clamp
if (p !== page){ page = p; loadData(page); }
if (!opts.keepInput) render();
else { totalEl.textContent = pages; prev.disabled = page<=1; next.disabled = page>=pages; }
}
// live (debounced) — only fires for in-range values
input.addEventListener('input', function(){
input.value = input.value.replace(/[^0-9]/g,''); // digits only
if (timer) clearTimeout(timer);
var raw = input.value;
if (raw === '') return; // wait, restore on blur
var n = parseInt(raw, 10);
if (n >= 1 && n <= pages){
timer = setTimeout(function(){ goTo(n, {keepInput:true}); }, delay);
}
});
// Enter → immediate
input.addEventListener('keydown', function(e){
if (e.key === 'Enter'){ if (timer) clearTimeout(timer); goTo(parseInt(input.value,10)); input.select(); }
});
// blur → commit / restore
input.addEventListener('blur', function(){
if (timer) clearTimeout(timer);
if (input.value === '' ) { input.value = page; return; }
goTo(parseInt(input.value,10));
});
prev.addEventListener('click', function(){ goTo(page-1); });
next.addEventListener('click', function(){ goTo(page+1); });
cfgPages.addEventListener('input', function(){
var v = parseInt(cfgPages.value,10); if(!v||v<1) return;
pages = v; if (page>pages) page=pages; render();
});
cfgDelay.addEventListener('change', function(){
delay = parseInt(cfgDelay.value,10);
delayLabel.innerHTML = delay+'&nbsp;ms';
});
// ---- "avant" rendering (numbered + ellipsis), mirrors current logic ----
function visiblePages(total, current){
if (total <= 5) return Array.from({length:total},function(_,i){return i+1;});
var out=[1];
if (current>3) out.push('…');
var s=Math.max(2,current-1), e=Math.min(total-1,current+1);
for(var i=s;i<=e;i++) out.push(i);
if (current<total-2) out.push('…');
if (total>1) out.push(total);
return out;
}
function renderOld(){
var bar = document.getElementById('old-bar');
bar.innerHTML='';
var prevB=document.createElement('span'); prevB.className='pg btn-like'; prevB.textContent=' Préc.'; bar.appendChild(prevB);
visiblePages(pages,page).forEach(function(p){
var el=document.createElement('span');
if(p==='…'){ el.className='dots'; el.textContent='…'; }
else { el.className='pg'+(p===page?' cur':''); el.textContent=p; }
bar.appendChild(el);
});
var nextB=document.createElement('span'); nextB.className='pg btn-like'; nextB.textContent='Suiv. '; bar.appendChild(nextB);
}
render();
})();
</script>
</body>
</html>
@@ -1,148 +0,0 @@
# 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…).