Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 747285ae3f | |||
| 26759395f9 | |||
| c6bca756f1 | |||
| f797c1c8a0 | |||
| b2c6f33e38 | |||
| ccd84d6d4a | |||
| bd9a204988 | |||
| 4bb152d87d | |||
| 2a818a0c77 | |||
| eb7677ae09 | |||
| b1c690e8bb | |||
| 59230bbc7e | |||
| 1560a23079 | |||
| 1cf7864f6e | |||
| eb9a00b6c8 | |||
| 887ebdebd7 | |||
| aedfaa865d | |||
| 39eb6e6068 | |||
| ce9b4853e6 | |||
| dc33cf4135 | |||
| 526dcd1a84 | |||
| 280b650e49 | |||
| 951acd448e | |||
| 90b81975e3 | |||
| e6a46a9d60 | |||
| 6efb830ffe | |||
| 7b838c60ca | |||
| 9551816bf8 | |||
| 7ac097e7f0 | |||
| bc813190c6 | |||
| f3e298e03b | |||
| e2dabb0a26 | |||
| ac06ed9ae6 | |||
| b2e3a83bb9 | |||
| 9ed094ba86 | |||
| 1ffe63827d | |||
| eb21827686 | |||
| 6938e730b6 | |||
| 174f1f9a64 | |||
| 30efd482d8 | |||
| 7dec45b374 | |||
| ea92acff3a | |||
| a3421c02e9 | |||
| 5563d89743 | |||
| 640ff90187 | |||
| 2eb7a5247a | |||
| 3336ff0c69 | |||
| da3a4cb349 | |||
| 0ddae4dd70 | |||
| 23210e6868 | |||
| 1c0fcd24e3 | |||
| d74f3acc97 | |||
| 014a057196 | |||
| 73483b0573 | |||
| 4855923008 | |||
| fc844078a6 | |||
| 02495245a5 | |||
| 330fb2130b | |||
| 5acefc1d59 | |||
| e77bf49146 | |||
| f59f866354 | |||
| 660c3787fd | |||
| e9741ff38d | |||
| 32608c8f71 | |||
| e1965db04e | |||
| 0ad344bab9 | |||
| 96719be78d | |||
| b90baec571 | |||
| 384f86a3b3 | |||
| e8ddf4e083 | |||
| 7ee64289a8 | |||
| f09f8a91ac | |||
| bcadd46ce2 | |||
| e76337502a | |||
| 968b7087b5 | |||
| 3deba3f369 | |||
| cf46ab0c85 | |||
| 09cc3edf6f | |||
| c95a3657c0 | |||
| 9843f4d032 | |||
| 9d9b9c9dc4 | |||
| 187ef52865 | |||
| 9925f1ced4 | |||
| ded414ba1a | |||
| 11d60e687b | |||
| d3038994c3 | |||
| 0d350e12c6 | |||
| c6acaace27 | |||
| 927c7c3c70 | |||
| bf0aa92497 | |||
| 88dd76a0e4 | |||
| cc04114f89 | |||
| f456ea4ddf | |||
| 77364daa67 | |||
| 1ab7b2427a | |||
| 82ecc9cfe2 | |||
| 65d9060e26 | |||
| ec4c157226 |
@@ -47,6 +47,20 @@ const paginatedItems = computed(() => {
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -88,5 +102,21 @@ function onRowClick(item: Record<string, unknown>) {
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -43,6 +43,10 @@ Liste des évolutions de la librairie Malio layer UI
|
||||
* [#MUI-41] InputEmail : sanitisation à la saisie (suppression des espaces, option `lowercase`)
|
||||
|
||||
### 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`.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -388,6 +388,7 @@ Liste déroulante.
|
||||
| `textField` | `string` | `'text-lg'` | Classe taille texte bouton |
|
||||
| `textValue` | `string` | `'text-lg'` | Classe taille texte valeur |
|
||||
| `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 |
|
||||
|
||||
**Events :** `update:modelValue(value: string | number | null)`
|
||||
@@ -956,6 +957,8 @@ 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.
|
||||
|
||||
**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 |
|
||||
|------|------|--------|-------------|
|
||||
| `id` | `string` | auto | Identifiant HTML |
|
||||
|
||||
@@ -162,8 +162,8 @@ describe('MalioButton', () => {
|
||||
it('applies correct dimensions', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
expect(wrapper.get('button').classes()).toContain('w-[200px]')
|
||||
expect(wrapper.get('button').classes()).toContain('h-[40px]')
|
||||
expect(wrapper.get('button').classes()).toContain('w-[180px]')
|
||||
expect(wrapper.get('button').classes()).toContain('h-[38px]')
|
||||
})
|
||||
|
||||
it('applies font styles', () => {
|
||||
|
||||
@@ -84,7 +84,7 @@ const variantClasses = computed(() => {
|
||||
|
||||
const mergedButtonClass = computed(() =>
|
||||
twMerge(
|
||||
'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',
|
||||
'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',
|
||||
variantClasses.value,
|
||||
props.buttonClass,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { DefineComponent } from 'vue'
|
||||
@@ -189,24 +189,6 @@ describe('MalioDataTable', () => {
|
||||
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', () => {
|
||||
const wrapper = mountComponent({ totalItems: 50, perPage: 10, page: 1 })
|
||||
expect(wrapper.find('[data-test="prev-button"]').attributes('disabled')).toBeDefined()
|
||||
@@ -229,26 +211,6 @@ describe('MalioDataTable', () => {
|
||||
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', () => {
|
||||
const wrapper = mountComponent({ totalItems: 30 })
|
||||
expect(wrapper.find('[data-test="pagination-nav"]').attributes('aria-label')).toBe('Pagination')
|
||||
@@ -265,6 +227,80 @@ 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', () => {
|
||||
it('emits update:per-page and reset page to 1 on change', async () => {
|
||||
const wrapper = mountComponent({ totalItems: 100, perPage: 10, page: 5 })
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
scope="col"
|
||||
class="border-b border-black px-3 py-3 text-left align-middle text-[20px]"
|
||||
class="border-b border-black px-3 py-3 text-left align-middle text-[16px]"
|
||||
>
|
||||
<slot
|
||||
v-if="$slots[`header-${col.key}`]"
|
||||
:name="`header-${col.key}`"
|
||||
:column="col"
|
||||
/>
|
||||
<span v-else class="font-semibold text-m-primary">{{ col.label }}</span>
|
||||
<span v-else class="font-semibold text-black">{{ col.label }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -32,7 +32,7 @@
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
class="px-3 py-4 text-[18px] text-m-primary"
|
||||
class="px-3 py-4 text-[14px] text-black"
|
||||
:class="index < items.length - 1 ? 'border-b border-black' : ''"
|
||||
>
|
||||
<slot
|
||||
@@ -57,16 +57,17 @@
|
||||
|
||||
<div
|
||||
v-if="totalItems > 0"
|
||||
class="flex items-center justify-between pt-2"
|
||||
class="flex items-center justify-between pt-3"
|
||||
data-test="pagination"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="whitespace-nowrap text-[16px] text-black">Lignes :</span>
|
||||
<div class="h-12">
|
||||
<div class="h-[30px]">
|
||||
<MalioSelect
|
||||
:model-value="perPage"
|
||||
:options="perPageSelectOptions"
|
||||
group-class="w-20"
|
||||
group-class="w-20 h-[30px]"
|
||||
field-class="h-[30px]"
|
||||
rounded="rounded"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
@@ -80,43 +81,39 @@
|
||||
<nav aria-label="Pagination" class="flex items-center gap-1" data-test="pagination-nav">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Prev"
|
||||
label="Préc."
|
||||
:disabled="page <= 1"
|
||||
button-class="h-10 w-auto min-w-0 px-3 text-sm"
|
||||
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)"
|
||||
@click="changePage(page - 1)"
|
||||
/>
|
||||
|
||||
<template v-for="(p, idx) in visiblePages" :key="idx">
|
||||
<span
|
||||
v-if="p === '...'"
|
||||
class="px-1 text-sm text-m-muted"
|
||||
aria-hidden="true"
|
||||
>…</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
|
||||
:class="p === page
|
||||
? 'bg-m-btn-primary text-white font-semibold'
|
||||
: 'text-m-text hover:bg-m-bg'"
|
||||
:aria-current="p === page ? 'page' : undefined"
|
||||
:data-test="`page-${p}`"
|
||||
@click="goToPage(p)"
|
||||
<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"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</template>
|
||||
<span class="text-m-muted">/ <span data-test="total-pages">{{ totalPages }}</span></span>
|
||||
</span>
|
||||
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Next"
|
||||
label="Suiv."
|
||||
:disabled="page >= totalPages"
|
||||
button-class="h-10 w-auto min-w-0 px-3 text-sm"
|
||||
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)"
|
||||
@click="changePage(page + 1)"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -124,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs, useId } from 'vue'
|
||||
import { computed, ref, watch, onBeforeUnmount, useAttrs, useId } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import MalioSelect from '../select/Select.vue'
|
||||
import MalioButton from '../button/Button.vue'
|
||||
@@ -172,6 +169,15 @@ const componentId = computed(() => props.id || `malio-datatable-${generatedId}`)
|
||||
|
||||
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(() =>
|
||||
props.perPageOptions.map(n => ({ label: String(n), value: n }))
|
||||
)
|
||||
@@ -183,42 +189,32 @@ function onPerPageChange(value: string | number | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
function changePage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value && page !== props.page) {
|
||||
emit('update:page', page)
|
||||
}
|
||||
}
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = totalPages.value
|
||||
const current = props.page
|
||||
|
||||
if (total <= 5) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const pages: (number | '...')[] = []
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...')
|
||||
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 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
|
||||
})
|
||||
const clamped = Math.min(Math.max(1, Math.round(n)), totalPages.value)
|
||||
changePage(clamped)
|
||||
pageInput.value = String(clamped)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
? 'border-black'
|
||||
: 'border-m-muted',
|
||||
disabled ? 'cursor-not-allowed border-m-muted text-black/60' : isReadonly ? 'cursor-default' : 'cursor-pointer',
|
||||
label ? 'min-h-[40px]' : 'h-[40px] py-0',
|
||||
twMerge(label ? 'min-h-[40px]' : 'h-[40px] py-0', fieldClass),
|
||||
rounded,
|
||||
textField,
|
||||
]"
|
||||
@@ -206,6 +206,7 @@ const props = withDefaults(defineProps<{
|
||||
textField?: string
|
||||
textValue?: string
|
||||
textLabel?: string
|
||||
fieldClass?: string
|
||||
rounded?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
@@ -223,6 +224,7 @@ const props = withDefaults(defineProps<{
|
||||
textField: 'text-lg',
|
||||
textValue: 'text-lg',
|
||||
textLabel: 'text-sm',
|
||||
fieldClass: '',
|
||||
rounded: 'rounded-md',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
|
||||
@@ -49,6 +49,22 @@
|
||||
</div>
|
||||
</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">
|
||||
<div class="p-4">
|
||||
<MalioDataTable
|
||||
@@ -192,4 +208,18 @@ const paginatedItems = computed(() => {
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,228 @@
|
||||
<!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 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 > 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+' 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>
|
||||
@@ -0,0 +1,148 @@
|
||||
# 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…).
|
||||
Reference in New Issue
Block a user