docs(datatable) : spec + sandbox pagination aller-à-la-page
This commit is contained in:
@@ -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