@zevra/ui 0.42.0 → 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1480 @@
1
+ 'use client'
2
+
3
+ // La grille de données : un tableau qu'on OPÈRE (trier, filtrer, réordonner,
4
+ // élargir, épingler, cocher, exporter), et qui se souvient de sa vue.
5
+ //
6
+ // ⚠️ CE N'EST PAS UN SECOND TABLEAU. TanStack Table calcule, `DataTable`
7
+ // rend (les mêmes .zv-table / .zv-row / .zv-cell que partout), et
8
+ // l'outillage vient des composants du paquet. Ce fichier ne fait que les
9
+ // brancher ensemble. L'arbitrage complet, ce qu'elle fait, ce qu'elle ne
10
+ // fait PAS et pourquoi : DECISIONS.md, « La grille de données ».
11
+ //
12
+ // ⚠️ SOUS-CHEMIN DÉDIÉ, hors du barrel, pour la même raison que
13
+ // `react/table` : TanStack et dnd-kit sont des dépendances OPTIONNELLES.
14
+ //
15
+ // import { DataGrid } from '@zevra/ui/react/grille'
16
+
17
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
18
+ import { createPortal } from 'react-dom'
19
+ import type {
20
+ CSSProperties,
21
+ KeyboardEvent as EvenementClavier,
22
+ PointerEvent as EvenementPointeur,
23
+ ReactNode,
24
+ } from 'react'
25
+ import {
26
+ getCoreRowModel,
27
+ getFacetedRowModel,
28
+ getFacetedUniqueValues,
29
+ getFilteredRowModel,
30
+ getPaginationRowModel,
31
+ getSortedRowModel,
32
+ useReactTable,
33
+ type Column,
34
+ type ColumnDef,
35
+ type ColumnFiltersState,
36
+ type ColumnOrderState,
37
+ type ColumnPinningState,
38
+ type ColumnSizingState,
39
+ type FilterFn,
40
+ type OnChangeFn,
41
+ type PaginationState,
42
+ type Row,
43
+ type RowSelectionState,
44
+ type SortingState,
45
+ type Table as TableTanstack,
46
+ type Updater,
47
+ type VisibilityState,
48
+ } from '@tanstack/react-table'
49
+ import {
50
+ DndContext,
51
+ KeyboardSensor,
52
+ PointerSensor,
53
+ closestCenter,
54
+ useDndMonitor,
55
+ useSensor,
56
+ useSensors,
57
+ type Announcements,
58
+ type DragEndEvent,
59
+ type Modifier,
60
+ type UniqueIdentifier,
61
+ } from '@dnd-kit/core'
62
+ import {
63
+ SortableContext,
64
+ arrayMove,
65
+ horizontalListSortingStrategy,
66
+ sortableKeyboardCoordinates,
67
+ useSortable,
68
+ } from '@dnd-kit/sortable'
69
+ import { DataTable, colonnesAffichees, epinglage, type CelluleEnteteProps } from './data-table.js'
70
+ import { usePersistedState } from './use-persisted-state.js'
71
+ import { Button } from './actions.js'
72
+ import { Icon, IconButton } from './icon.js'
73
+ import { Checkbox, Field, Input, Search, SelectAnchor } from './forms.js'
74
+ import { SelectMenu } from './select-menu.js'
75
+ import { Menu, MenuItem, Pager, PagerArrow, Popover } from './navigation.js'
76
+ import { Empty } from './feedback.js'
77
+ import { Meta } from './data.js'
78
+ import { cx } from './cx.js'
79
+
80
+ export { usePersistedState }
81
+
82
+ /** Identifiant réservé de la colonne de cases. */
83
+ export const COLONNE_SELECTION = '__selection'
84
+
85
+ /** Ce que la grille enregistre, par tableau. Les filtres et la sélection
86
+ * n'en font PAS partie : retrouver un filtre oublié d'une visite à l'autre
87
+ * fait croire que des lignes ont disparu. */
88
+ export interface VueGrille {
89
+ columnOrder: ColumnOrderState
90
+ columnVisibility: VisibilityState
91
+ columnSizing: ColumnSizingState
92
+ columnPinning: ColumnPinningState
93
+ sorting: SortingState
94
+ pageSize: number
95
+ }
96
+
97
+ /** Les tailles de page proposées au pied. */
98
+ export const TAILLES_PAGE = [25, 50, 100] as const
99
+
100
+ /** Toute la copie de la grille. Aucun libellé, visible ou annoncé, n'est
101
+ * écrit en dur : une app anglaise passe les siens par `textes`, sans quoi
102
+ * elle n'aurait AUCUN recours (le CSS ne traduit pas un aria-label). */
103
+ export interface TextesGrille {
104
+ rechercher: string
105
+ colonnes: string
106
+ colonnesAffichees: string
107
+ exporter: string
108
+ reinitialiser: string
109
+ filtrer: string
110
+ /** Première option d'un filtre à choix : aucune restriction. */
111
+ tous: string
112
+ du: string
113
+ au: string
114
+ min: string
115
+ max: string
116
+ effacer: string
117
+ /** Raccourcis de période du filtre de dates. */
118
+ periodes: string
119
+ aujourdhui: string
120
+ septJours: string
121
+ trenteJours: string
122
+ ceMois: string
123
+ cetteAnnee: string
124
+ /** Résumé d'une plage dans son déclencheur (« ≥ 10 », « 01/03 → 15/03 »).
125
+ * Les dates arrivent en ISO (AAAA-MM-JJ). */
126
+ resumeNombres: (min: string, max: string) => string
127
+ resumeDates: (du: string, au: string) => string
128
+ filtrerColonne: (nom: string) => string
129
+ deplacer: (nom: string) => string
130
+ largeur: (nom: string) => string
131
+ largeurAide: string
132
+ epinglerGauche: (nom: string) => string
133
+ epinglerDroite: (nom: string) => string
134
+ detacher: (nom: string) => string
135
+ aGauche: string
136
+ aDroite: string
137
+ toutSelectionner: string
138
+ selectionnerLigne: string
139
+ choisies: (n: number) => string
140
+ aucuneDonnee: string
141
+ aucunResultat: string
142
+ aucuneLigne: string
143
+ compte: (debut: number, fin: number, total: number) => string
144
+ parPage: (n: number) => string
145
+ lignesParPage: string
146
+ pagination: string
147
+ pagePrecedente: string
148
+ pageSuivante: string
149
+ page: (page: number, pages: number) => string
150
+ dndInstructions: string
151
+ dndSaisie: (nom: string) => string
152
+ dndSurvol: (nom: string, cible: string) => string
153
+ dndHors: (nom: string) => string
154
+ dndPose: (nom: string, cible: string) => string
155
+ dndReposee: (nom: string) => string
156
+ dndAnnule: (nom: string) => string
157
+ }
158
+
159
+ export const TEXTES_GRILLE: TextesGrille = {
160
+ rechercher: 'Rechercher dans le tableau',
161
+ colonnes: 'Colonnes',
162
+ colonnesAffichees: 'Colonnes affichées',
163
+ exporter: 'Exporter',
164
+ reinitialiser: 'Réinitialiser la vue',
165
+ filtrer: 'Filtrer',
166
+ tous: 'Tous',
167
+ du: 'Du',
168
+ au: 'Au',
169
+ min: 'Min.',
170
+ max: 'Max.',
171
+ effacer: 'Effacer le filtre',
172
+ periodes: 'Périodes',
173
+ aujourdhui: "Aujourd'hui",
174
+ septJours: '7 derniers jours',
175
+ trenteJours: '30 derniers jours',
176
+ ceMois: 'Ce mois-ci',
177
+ cetteAnnee: 'Cette année',
178
+ resumeNombres: (min, max) =>
179
+ min && max ? `${min} – ${max}` : min ? `≥ ${min}` : max ? `≤ ${max}` : 'Tous',
180
+ resumeDates: (du, au) => {
181
+ const fr = (d: string) => d.split('-').reverse().join('/')
182
+ return du && au ? `${fr(du)} → ${fr(au)}` : du ? `dès le ${fr(du)}` : au ? `jusqu'au ${fr(au)}` : 'Toutes'
183
+ },
184
+ filtrerColonne: (nom) => `Filtrer la colonne ${nom}`,
185
+ deplacer: (nom) => `Déplacer la colonne ${nom}`,
186
+ largeur: (nom) => `Largeur de la colonne ${nom}`,
187
+ largeurAide: "Glisser pour élargir. Double-clic : largeur d'origine.",
188
+ epinglerGauche: (nom) => `Épingler ${nom} à gauche`,
189
+ epinglerDroite: (nom) => `Épingler ${nom} à droite`,
190
+ detacher: (nom) => `Détacher ${nom}`,
191
+ aGauche: 'à gauche',
192
+ aDroite: 'à droite',
193
+ toutSelectionner: 'Sélectionner toutes les lignes',
194
+ selectionnerLigne: 'Sélectionner la ligne',
195
+ choisies: (n) => (n > 1 ? `${n} lignes choisies` : `${n} ligne choisie`),
196
+ aucuneDonnee: 'Aucune ligne pour le moment.',
197
+ aucunResultat: 'Aucune ligne ne correspond à ces filtres.',
198
+ aucuneLigne: 'Aucune ligne.',
199
+ compte: (debut, fin, total) => (total === 0 ? '0 ligne' : `${debut} – ${fin} sur ${total}`),
200
+ parPage: (n) => `${n} par page`,
201
+ lignesParPage: 'Lignes par page',
202
+ pagination: 'Pagination du tableau',
203
+ pagePrecedente: 'Page précédente',
204
+ pageSuivante: 'Page suivante',
205
+ page: (page, pages) => `p. ${page} / ${pages}`,
206
+ dndInstructions:
207
+ 'Pour déplacer la colonne, appuyez sur Espace, puis sur les flèches gauche et droite. Espace pour la poser, Échap pour annuler.',
208
+ dndSaisie: (nom) => `Colonne ${nom} saisie.`,
209
+ dndSurvol: (nom, cible) => `Colonne ${nom} à la place de ${cible}.`,
210
+ dndHors: (nom) => `Colonne ${nom} hors des colonnes.`,
211
+ dndPose: (nom, cible) => `Colonne ${nom} posée à la place de ${cible}.`,
212
+ dndReposee: (nom) => `Colonne ${nom} reposée.`,
213
+ dndAnnule: (nom) => `Déplacement annulé : la colonne ${nom} reprend sa place.`,
214
+ }
215
+
216
+ export interface DataGridProps<T> {
217
+ /** Identifiant STABLE du tableau : il nomme la vue enregistrée
218
+ * (`table:<tableId>:v1`). Deux tableaux qui le partagent partagent leur
219
+ * vue ; le changer fait oublier celle de tous les utilisateurs. */
220
+ tableId: string
221
+ data: T[]
222
+ /** Colonnes TanStack. Chacune doit avoir un `id` ou un `accessorKey`
223
+ * textuel : c'est lui que la vue enregistre. `size` est la largeur
224
+ * PLANCHER en pixels (150 par défaut) : la colonne s'étire au-delà tant
225
+ * qu'on ne l'a pas réglée. `meta.registre`, `meta.label`, `meta.unite`
226
+ * sont ceux de `DataTable`. */
227
+ columns: ColumnDef<T, any>[]
228
+ /** Clic (ou Entrée) sur une ligne. Sans lui, la ligne n'est pas
229
+ * cliquable, et elle n'en a pas l'air. */
230
+ onRowClick?: (ligne: T) => void
231
+ /** La ligne dont l'aperçu est ouvert : filet d'accent à gauche. */
232
+ ligneActive?: (ligne: T) => boolean
233
+ /** Identifiant de ligne. Indispensable avec `selection` dès que les
234
+ * données se rechargent : par défaut c'est l'INDEX, et une ligne cochée
235
+ * deviendrait une autre ligne après un tri côté serveur. */
236
+ getRowId?: (ligne: T, index: number) => string
237
+ /** Une colonne de cases, épinglée à gauche. */
238
+ selection?: boolean
239
+ onSelectionChange?: (lignes: T[]) => void
240
+ /** Lignes par page (25, 50 ou 100), ou `false` pour tout afficher. */
241
+ parPage?: (typeof TAILLES_PAGE)[number] | false
242
+ /** Champ de recherche sur toutes les colonnes. Par défaut : oui. */
243
+ recherche?: boolean
244
+ /** Rangée de filtres sous les intitulés. Par défaut : oui. */
245
+ filtres?: boolean
246
+ /** Bouton d'export CSV des lignes filtrées et triées, colonnes visibles.
247
+ * Une chaîne nomme le fichier (sans extension). Par défaut : oui. */
248
+ exportCsv?: boolean | string
249
+ /** Vue par défaut : colonnes masquées, épinglées, tri. C'est elle que
250
+ * « Réinitialiser la vue » rétablit. */
251
+ vueInitiale?: {
252
+ masquees?: string[]
253
+ epingleesGauche?: string[]
254
+ epingleesDroite?: string[]
255
+ tri?: SortingState
256
+ }
257
+ /** Ligne d'absence quand aucune ligne ne correspond. */
258
+ vide?: ReactNode
259
+ /** Actions propres à l'app, posées dans la barre avant les réglages de
260
+ * vue. ⚠️ Un seul bouton plein par page : la grille n'en pose aucun, le
261
+ * vôtre peut être celui-là. */
262
+ outils?: ReactNode
263
+ /** Copie de la grille, à remplacer en tout ou partie (traduction). */
264
+ textes?: Partial<TextesGrille>
265
+ className?: string
266
+ }
267
+
268
+ /* ─── Fonctions pures (testées) ──────────────────────────────────────── */
269
+
270
+ /** L'identifiant d'une définition de colonne, tel que TanStack le dérive. */
271
+ export function idDeColonne<T>(c: ColumnDef<T, any>): string | undefined {
272
+ if (c.id) return c.id
273
+ const cle = (c as { accessorKey?: unknown }).accessorKey
274
+ return typeof cle === 'string' ? cle.replace(/\./g, '_') : undefined
275
+ }
276
+
277
+ const estObjet = (v: unknown): v is Record<string, unknown> =>
278
+ typeof v === 'object' && v !== null && !Array.isArray(v)
279
+
280
+ const listeIds = (v: unknown, connus: Set<string>): string[] =>
281
+ Array.isArray(v)
282
+ ? [...new Set(v.filter((x): x is string => typeof x === 'string' && connus.has(x)))]
283
+ : []
284
+
285
+ /**
286
+ * Adapte une vue LUE (d'une version précédente du code, ou abîmée) aux
287
+ * colonnes d'aujourd'hui : un identifiant qui n'existe plus est ignoré, une
288
+ * colonne nouvelle s'ajoute en FIN d'ordre. Sans cela, ajouter une colonne
289
+ * au code la ferait disparaître chez tous ceux qui avaient déplacé une
290
+ * colonne avant. Rend `undefined` si rien n'est exploitable.
291
+ */
292
+ export function reconcilierVue(
293
+ lu: unknown,
294
+ defaut: VueGrille,
295
+ ids: string[],
296
+ ): VueGrille | undefined {
297
+ if (!estObjet(lu)) return undefined
298
+ const connus = new Set(ids)
299
+
300
+ const ordreLu = listeIds(lu.columnOrder, connus)
301
+ const columnOrder = ordreLu.length
302
+ ? [...ordreLu, ...ids.filter((id) => !ordreLu.includes(id))]
303
+ : defaut.columnOrder
304
+
305
+ const filtrer = <V,>(objet: unknown, garder: (v: unknown) => v is V): Record<string, V> => {
306
+ const sortie: Record<string, V> = {}
307
+ if (estObjet(objet)) {
308
+ for (const [k, v] of Object.entries(objet)) if (connus.has(k) && garder(v)) sortie[k] = v
309
+ }
310
+ return sortie
311
+ }
312
+ const columnVisibility = estObjet(lu.columnVisibility)
313
+ ? filtrer(lu.columnVisibility, (v): v is boolean => typeof v === 'boolean')
314
+ : defaut.columnVisibility
315
+ const columnSizing = filtrer(
316
+ lu.columnSizing,
317
+ (v): v is number => typeof v === 'number' && Number.isFinite(v) && v > 0,
318
+ )
319
+
320
+ let columnPinning = defaut.columnPinning
321
+ if (estObjet(lu.columnPinning)) {
322
+ const left = listeIds(lu.columnPinning.left, connus)
323
+ const right = listeIds(lu.columnPinning.right, connus).filter((id) => !left.includes(id))
324
+ columnPinning = { left, right }
325
+ }
326
+
327
+ const sorting = Array.isArray(lu.sorting)
328
+ ? lu.sorting
329
+ .filter(
330
+ (s): s is { id: string; desc: boolean } =>
331
+ estObjet(s) && typeof s.id === 'string' && connus.has(s.id) && typeof s.desc === 'boolean',
332
+ )
333
+ .map((s) => ({ id: s.id, desc: s.desc }))
334
+ : defaut.sorting
335
+
336
+ const pageSize = (TAILLES_PAGE as readonly unknown[]).includes(lu.pageSize)
337
+ ? (lu.pageSize as number)
338
+ : defaut.pageSize
339
+
340
+ return { columnOrder, columnVisibility, columnSizing, columnPinning, sorting, pageSize }
341
+ }
342
+
343
+ /** Les quatre familles de filtre. Elles se DÉDUISENT des données (voir
344
+ * `typeDeFiltre`) ; une colonne peut l'imposer par `meta.filtre`. */
345
+ export type TypeFiltre = 'texte' | 'nombre' | 'date' | 'choix'
346
+
347
+ /** La valeur d'un filtre de colonne, telle que TanStack la tient. */
348
+ export type ValeurFiltre =
349
+ | { type: 'texte'; valeur: string }
350
+ | { type: 'choix'; valeur: string }
351
+ | { type: 'nombre'; min: string; max: string }
352
+ | { type: 'date'; du: string; au: string }
353
+
354
+ /** Une valeur ramenée au jour, `AAAA-MM-JJ`, dans le fuseau du NAVIGATEUR :
355
+ * une échéance au 3 mars à 23 h 30 à Paris est le 3 mars, pas le 4 qu'en
356
+ * dirait `toISOString()`. Rend `null` pour ce qui n'est pas une date. */
357
+ export function versJour(v: unknown): string | null {
358
+ if (v instanceof Date) {
359
+ if (Number.isNaN(v.getTime())) return null
360
+ const deux = (n: number) => String(n).padStart(2, '0')
361
+ return `${v.getFullYear()}-${deux(v.getMonth() + 1)}-${deux(v.getDate())}`
362
+ }
363
+ if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v)) return v.slice(0, 10)
364
+ return null
365
+ }
366
+
367
+ /** Au-delà, une liste déroulante devient plus longue à parcourir qu'un mot
368
+ * à taper : la colonne reste en texte libre. */
369
+ export const CHOIX_MAX = 20
370
+
371
+ /**
372
+ * La famille de filtre d'une colonne, d'après ses valeurs : `meta.filtre`
373
+ * d'abord, puis un nombre (registre `num`, ou valeurs numériques), une date
374
+ * (objets `Date` ou chaînes ISO), un CHOIX quand les valeurs distinctes sont
375
+ * peu nombreuses ET se répètent (sinon chaque ligne serait une option), du
376
+ * texte enfin.
377
+ */
378
+ export function typeDeFiltre(
379
+ meta: { filtre?: TypeFiltre | false; registre?: 'nom' | 'num' } | undefined,
380
+ valeurs: unknown[],
381
+ ): TypeFiltre | false {
382
+ if (meta?.filtre !== undefined) return meta.filtre
383
+ if (meta?.registre === 'num') return 'nombre'
384
+ const presentes = valeurs.filter((v) => v != null && v !== '')
385
+ if (presentes.length === 0) return 'texte'
386
+ if (presentes.every((v) => typeof v === 'number')) return 'nombre'
387
+ if (presentes.every((v) => versJour(v) !== null)) return 'date'
388
+ if (meta?.registre !== 'nom' && presentes.every((v) => typeof v === 'string' || typeof v === 'boolean')) {
389
+ const distinctes = new Set(presentes.map(String)).size
390
+ if (distinctes <= CHOIX_MAX && distinctes < presentes.length) return 'choix'
391
+ }
392
+ return 'texte'
393
+ }
394
+
395
+ const nombre = (s: string): number | null => {
396
+ if (s.trim() === '') return null
397
+ const n = Number(s.replace(',', '.'))
398
+ return Number.isFinite(n) ? n : null
399
+ }
400
+
401
+ /** Le filtre de toutes les colonnes de la grille : il lit la FORME de la
402
+ * valeur, pas la colonne. Bornes comprises. */
403
+ export const filtreGrille: FilterFn<any> = (ligne, colonne, filtre: ValeurFiltre) => {
404
+ const brut = ligne.getValue(colonne)
405
+ switch (filtre?.type) {
406
+ case 'texte':
407
+ return String(brut ?? '').toLowerCase().includes(filtre.valeur.trim().toLowerCase())
408
+ case 'choix':
409
+ return String(brut ?? '') === filtre.valeur
410
+ case 'nombre': {
411
+ const v = typeof brut === 'number' ? brut : nombre(String(brut ?? ''))
412
+ const min = nombre(filtre.min)
413
+ const max = nombre(filtre.max)
414
+ if (v == null) return min == null && max == null
415
+ return (min == null || v >= min) && (max == null || v <= max)
416
+ }
417
+ case 'date': {
418
+ const jourLigne = versJour(brut)
419
+ if (jourLigne == null) return !filtre.du && !filtre.au
420
+ return (!filtre.du || jourLigne >= filtre.du) && (!filtre.au || jourLigne <= filtre.au)
421
+ }
422
+ default:
423
+ return true
424
+ }
425
+ }
426
+ filtreGrille.autoRemove = (f: ValeurFiltre | undefined) =>
427
+ !f ||
428
+ (f.type === 'texte' && f.valeur.trim() === '') ||
429
+ (f.type === 'choix' && f.valeur === '') ||
430
+ (f.type === 'nombre' && f.min === '' && f.max === '') ||
431
+ (f.type === 'date' && f.du === '' && f.au === '')
432
+
433
+ /** Ne garde d'une saisie que ce qui peut faire un nombre : chiffres, un
434
+ * signe moins en tête, un séparateur décimal. Les lettres ne s'écrivent
435
+ * pas, plutôt que de s'écrire puis d'être refusées. */
436
+ export function saisieNombre(texte: string): string {
437
+ const signe = texte.trimStart().startsWith('-') ? '-' : ''
438
+ const [entier, ...reste] = texte.replace(/[^\d.,]/g, '').split(/[.,]/)
439
+ return signe + entier + (reste.length ? ',' + reste.join('') : '')
440
+ }
441
+
442
+ /** Une date saisie est-elle COMPLÈTE ? Le champ date natif rend une valeur
443
+ * dès que les trois segments sont remplis, y compris pendant qu'on tape
444
+ * l'année : « 0002 », puis « 0020 », puis « 0202 ». Filtrer sur ces états
445
+ * intermédiaires faisait disparaître puis revenir toutes les lignes à
446
+ * chaque chiffre. On n'applique qu'une année plausible. */
447
+ export function dateComplete(v: string): boolean {
448
+ return v === '' || (/^\d{4}-\d{2}-\d{2}$/.test(v) && Number(v.slice(0, 4)) >= 1900)
449
+ }
450
+
451
+ /** Les raccourcis de période, en jours ISO, calculés depuis `maintenant`. */
452
+ export function periodes(maintenant: Date): Record<
453
+ 'aujourdhui' | 'septJours' | 'trenteJours' | 'ceMois' | 'cetteAnnee',
454
+ [string, string]
455
+ > {
456
+ const jour = (d: Date) => versJour(d) as string
457
+ const decale = (n: number) => {
458
+ const d = new Date(maintenant)
459
+ d.setDate(d.getDate() - n)
460
+ return jour(d)
461
+ }
462
+ const aujourdhui = jour(maintenant)
463
+ return {
464
+ aujourdhui: [aujourdhui, aujourdhui],
465
+ septJours: [decale(6), aujourdhui],
466
+ trenteJours: [decale(29), aujourdhui],
467
+ ceMois: [jour(new Date(maintenant.getFullYear(), maintenant.getMonth(), 1)), aujourdhui],
468
+ cetteAnnee: [jour(new Date(maintenant.getFullYear(), 0, 1)), aujourdhui],
469
+ }
470
+ }
471
+
472
+ /** L'intitulé lisible d'une colonne : pour les menus, l'export, les annonces. */
473
+ export function libelleColonne<T>(c: Column<T, unknown>): string {
474
+ const h = c.columnDef.header
475
+ return typeof h === 'string' ? h : (c.columnDef.meta?.label ?? c.id)
476
+ }
477
+
478
+ /** Les lignes filtrées et triées, colonnes visibles, en CSV : point-virgule
479
+ * (le séparateur qu'Excel attend en français), guillemets doublés, dates en
480
+ * ISO. Les VALEURS, pas le rendu : une pastille d'état s'exporte par son
481
+ * libellé de données, pas par son HTML. */
482
+ export function versCsv<T>(table: TableTanstack<T>): string {
483
+ const colonnes = colonnesAffichees(table).filter((c) => c.id !== COLONNE_SELECTION)
484
+ const echapper = (v: unknown): string => {
485
+ const s =
486
+ v == null ? '' : v instanceof Date ? v.toISOString().slice(0, 10) : String(v)
487
+ return /[";\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
488
+ }
489
+ const lignes = table.getPrePaginationRowModel().rows
490
+ return [
491
+ colonnes.map((c) => echapper(libelleColonne(c))).join(';'),
492
+ ...lignes.map((l) => colonnes.map((c) => echapper(l.getValue(c.id))).join(';')),
493
+ ].join('\r\n')
494
+ }
495
+
496
+ function telecharger(texte: string, nom: string) {
497
+ // BOM UTF-8 : sans lui, Excel ouvre le fichier en Windows-1252 et
498
+ // « Déposé » devient « Déposé ».
499
+ const url = URL.createObjectURL(new Blob(['' + texte], { type: 'text/csv;charset=utf-8' }))
500
+ const lien = document.createElement('a')
501
+ lien.href = url
502
+ lien.download = `${nom}.csv`
503
+ lien.click()
504
+ setTimeout(() => URL.revokeObjectURL(url), 0)
505
+ }
506
+
507
+ const appliquer = <V,>(u: Updater<V>, ancien: V): V =>
508
+ typeof u === 'function' ? (u as (o: V) => V)(ancien) : u
509
+
510
+ /* ─── En-tête : poignée de déplacement et poignée de largeur ─────────── */
511
+
512
+ // Le glisser-déposer ne bouge que sur l'axe des colonnes : sans ce
513
+ // modificateur, l'en-tête saisi suit le pointeur en hauteur et sort du cadre.
514
+ const axeHorizontal: Modifier = ({ transform }) => ({ ...transform, y: 0 })
515
+
516
+ function EnteteColonne<T>(p: CelluleEnteteProps<T> & { t: TextesGrille }) {
517
+ const colonne = p.entete.column
518
+ const table = p.entete.getContext().table
519
+ const nom = libelleColonne(colonne)
520
+ const t = p.t
521
+ // Ni la colonne de cases ni une colonne épinglée ne se déplacent : elles
522
+ // ont une place fixe, et TanStack les rend hors de l'ordre de toute façon.
523
+ const deplacable = colonne.id !== COLONNE_SELECTION && !colonne.getIsPinned()
524
+ const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } =
525
+ useSortable({ id: colonne.id, disabled: !deplacable })
526
+
527
+ // Tout l'en-tête se saisit, comme dans un tableur : viser une poignée de
528
+ // dix pixels n'est pas un geste qu'on devine. Le clic simple TRIE toujours,
529
+ // le seuil de 4 px du capteur les départage. Le CLAVIER, lui, passe par la
530
+ // poignée : c'est le seul élément focusable qui porte ce rôle.
531
+ const { onPointerDown, onKeyDown } = (listeners ?? {}) as {
532
+ onPointerDown?: (e: EvenementPointeur<HTMLElement>) => void
533
+ onKeyDown?: (e: EvenementClavier<HTMLElement>) => void
534
+ }
535
+
536
+ // Au lâcher, le navigateur émet un clic sur ce qui est sous le pointeur :
537
+ // un intitulé, qui trierait. On l'avale pendant un instant, sur TOUS les
538
+ // en-têtes, puisque la colonne peut être posée sur n'importe lequel.
539
+ const finDeplacement = useRef(0)
540
+ useDndMonitor({
541
+ onDragEnd: () => {
542
+ finDeplacement.current = Date.now()
543
+ },
544
+ onDragCancel: () => {
545
+ finDeplacement.current = Date.now()
546
+ },
547
+ })
548
+
549
+ const style: CSSProperties = {
550
+ ...p.style,
551
+ transform: transform ? `translate3d(${Math.round(transform.x)}px, 0, 0)` : undefined,
552
+ transition,
553
+ }
554
+
555
+ return (
556
+ <div
557
+ ref={setNodeRef}
558
+ className={cx(
559
+ p.className,
560
+ 'zv-colhead',
561
+ deplacable && 'zv-colhead--deplacable',
562
+ isDragging && 'zv-colhead--drag',
563
+ colonne.columnDef.meta?.classe,
564
+ )}
565
+ style={style}
566
+ aria-sort={p['aria-sort']}
567
+ onPointerDown={deplacable ? onPointerDown : undefined}
568
+ onClickCapture={(e) => {
569
+ if (Date.now() - finDeplacement.current < 250) {
570
+ e.preventDefault()
571
+ e.stopPropagation()
572
+ }
573
+ }}
574
+ >
575
+ {deplacable && (
576
+ <button
577
+ type="button"
578
+ ref={setActivatorNodeRef}
579
+ className="zv-colhead__grip"
580
+ {...attributes}
581
+ onKeyDown={onKeyDown}
582
+ aria-label={t.deplacer(nom)}
583
+ >
584
+ <Icon nom="menu" taille="sm" />
585
+ </button>
586
+ )}
587
+ {p.children}
588
+ {colonne.getCanResize() && <PoigneeLargeur colonne={colonne} table={table} nom={nom} t={t} />}
589
+ </div>
590
+ )
591
+ }
592
+
593
+ /**
594
+ * Le redimensionnement est ÉCRIT ICI, pas confié à `header.getResizeHandler()` :
595
+ * celui-ci part de `getSize()`, qui vaut le plancher (150 px) pour une colonne
596
+ * souple quelle que soit sa largeur à l'écran, et le premier geste faisait
597
+ * sauter la colonne. On mesure la largeur RÉELLE à l'appui, puis on écrit
598
+ * `columnSizing` à chaque mouvement : c'est le mode `onChange`.
599
+ */
600
+ function PoigneeLargeur<T>({
601
+ colonne,
602
+ table,
603
+ nom,
604
+ t,
605
+ }: {
606
+ colonne: Column<T, unknown>
607
+ table: TableTanstack<T>
608
+ nom: string
609
+ t: TextesGrille
610
+ }) {
611
+ const ref = useRef<HTMLSpanElement>(null)
612
+ const depart = useRef<{ x: number; largeur: number } | null>(null)
613
+ const [actif, setActif] = useState(false)
614
+ const min = colonne.columnDef.minSize ?? 60
615
+ const max = colonne.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER
616
+
617
+ const largeurActuelle = () =>
618
+ table.getState().columnSizing[colonne.id] ??
619
+ ref.current?.parentElement?.getBoundingClientRect().width ??
620
+ colonne.getSize()
621
+
622
+ const regler = (largeur: number) => {
623
+ const l = Math.round(Math.min(max, Math.max(min, largeur)))
624
+ table.setColumnSizing((ancien) => ({ ...ancien, [colonne.id]: l }))
625
+ }
626
+ const rendre = () =>
627
+ table.setColumnSizing((ancien) => {
628
+ const { [colonne.id]: _retire, ...reste } = ancien
629
+ return reste
630
+ })
631
+
632
+ const appui = (e: EvenementPointeur<HTMLSpanElement>) => {
633
+ if (e.button !== 0) return
634
+ e.preventDefault()
635
+ e.stopPropagation()
636
+ e.currentTarget.setPointerCapture(e.pointerId)
637
+ depart.current = { x: e.clientX, largeur: largeurActuelle() }
638
+ setActif(true)
639
+ }
640
+ const mouvement = (e: EvenementPointeur<HTMLSpanElement>) => {
641
+ if (!depart.current) return
642
+ regler(depart.current.largeur + e.clientX - depart.current.x)
643
+ }
644
+ const fin = (e: EvenementPointeur<HTMLSpanElement>) => {
645
+ if (!depart.current) return
646
+ depart.current = null
647
+ setActif(false)
648
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
649
+ }
650
+ const clavier = (e: EvenementClavier<HTMLSpanElement>) => {
651
+ const pas = e.shiftKey ? 50 : 10
652
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
653
+ e.preventDefault()
654
+ regler(largeurActuelle() + (e.key === 'ArrowRight' ? pas : -pas))
655
+ } else if (e.key === 'Enter' || e.key === 'Delete' || e.key === 'Backspace') {
656
+ e.preventDefault()
657
+ rendre()
658
+ }
659
+ }
660
+
661
+ const reglee = table.getState().columnSizing[colonne.id]
662
+ return (
663
+ <span
664
+ ref={ref}
665
+ // Le motif ARIA du séparateur DÉPLAÇABLE (window splitter) : focusable,
666
+ // une valeur, et les flèches pour la changer.
667
+ role="separator"
668
+ aria-orientation="vertical"
669
+ aria-label={t.largeur(nom)}
670
+ aria-valuenow={Math.round(reglee ?? colonne.getSize())}
671
+ aria-valuemin={min}
672
+ tabIndex={0}
673
+ title={t.largeurAide}
674
+ className={cx('zv-colhead__resize', actif && 'zv-colhead__resize--actif')}
675
+ onPointerDown={appui}
676
+ onPointerMove={mouvement}
677
+ onPointerUp={fin}
678
+ onPointerCancel={fin}
679
+ onDoubleClick={rendre}
680
+ onKeyDown={clavier}
681
+ onClick={(e) => e.stopPropagation()}
682
+ />
683
+ )
684
+ }
685
+
686
+ /* ─── Cases de sélection ─────────────────────────────────────────────── */
687
+
688
+ function CaseTout<T>({ table, t }: { table: TableTanstack<T>; t: TextesGrille }) {
689
+ // Une enveloppe plutôt qu'une ref sur la case : `Checkbox` ne transmet
690
+ // pas de ref sous React 18, que le paquet accepte encore.
691
+ const ref = useRef<HTMLSpanElement>(null)
692
+ const partiel = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
693
+ // `indeterminate` n'a pas d'attribut HTML : seule la propriété DOM existe.
694
+ useEffect(() => {
695
+ const caseTout = ref.current?.querySelector('input')
696
+ if (caseTout) caseTout.indeterminate = partiel
697
+ }, [partiel])
698
+ return (
699
+ <span ref={ref} className="zv-cell--case">
700
+ <Checkbox
701
+ checked={table.getIsAllRowsSelected()}
702
+ onChange={table.getToggleAllRowsSelectedHandler()}
703
+ aria-label={t.toutSelectionner}
704
+ />
705
+ </span>
706
+ )
707
+ }
708
+
709
+ function colonneSelection<T>(t: TextesGrille): ColumnDef<T, any> {
710
+ return {
711
+ id: COLONNE_SELECTION,
712
+ size: 40,
713
+ minSize: 40,
714
+ enableSorting: false,
715
+ enableHiding: false,
716
+ enableResizing: false,
717
+ enableColumnFilter: false,
718
+ enableGlobalFilter: false,
719
+ enablePinning: false,
720
+ header: ({ table }) => <CaseTout table={table} t={t} />,
721
+ cell: ({ row }: { row: Row<T> }) => (
722
+ <Checkbox
723
+ checked={row.getIsSelected()}
724
+ disabled={!row.getCanSelect()}
725
+ onChange={row.getToggleSelectedHandler()}
726
+ // La case vit dans une ligne cliquable : sans ces deux arrêts, cocher
727
+ // ouvrirait aussi la ligne, et Espace l'ouvrirait au clavier.
728
+ onClick={(e) => e.stopPropagation()}
729
+ onKeyDown={(e) => e.stopPropagation()}
730
+ aria-label={t.selectionnerLigne}
731
+ />
732
+ ),
733
+ meta: { classe: 'zv-cell--case' },
734
+ }
735
+ }
736
+
737
+ /* ─── Menu des colonnes : visibilité et épinglage ────────────────────── */
738
+
739
+ function MenuColonnes<T>({ table, t }: { table: TableTanstack<T>; t: TextesGrille }) {
740
+ const [ouvert, setOuvert] = useState(false)
741
+ const ancre = useRef<HTMLDivElement>(null)
742
+
743
+ // Fermeture au clic extérieur et à Échap, focus rendu au déclencheur :
744
+ // c'est à l'appelant de le faire, le Menu du paquet ne fait qu'afficher.
745
+ useEffect(() => {
746
+ if (!ouvert) return
747
+ const document_ = ancre.current?.ownerDocument ?? document
748
+ const dehors = (e: MouseEvent) => {
749
+ if (!ancre.current?.contains(e.target as Node)) setOuvert(false)
750
+ }
751
+ const echap = (e: KeyboardEvent) => {
752
+ if (e.key === 'Escape') {
753
+ setOuvert(false)
754
+ ancre.current?.querySelector('button')?.focus()
755
+ }
756
+ }
757
+ document_.addEventListener('mousedown', dehors)
758
+ document_.addEventListener('keydown', echap)
759
+ return () => {
760
+ document_.removeEventListener('mousedown', dehors)
761
+ document_.removeEventListener('keydown', echap)
762
+ }
763
+ }, [ouvert])
764
+
765
+ const colonnes = table.getAllLeafColumns().filter((c) => c.id !== COLONNE_SELECTION)
766
+ return (
767
+ <div ref={ancre}>
768
+ <SelectAnchor>
769
+ <Button
770
+ variante="fantome"
771
+ compact
772
+ icone="reglages"
773
+ aria-expanded={ouvert}
774
+ aria-haspopup="true"
775
+ onClick={() => setOuvert((o) => !o)}
776
+ >
777
+ {t.colonnes}
778
+ </Button>
779
+ {ouvert && (
780
+ <Menu role="group" aria-label={t.colonnesAffichees} className="zv-grille__menu">
781
+ {colonnes.map((c) => {
782
+ const nom = libelleColonne(c)
783
+ const cote = c.getIsPinned()
784
+ const suivant = cote === false ? 'left' : cote === 'left' ? 'right' : false
785
+ return (
786
+ <div key={c.id} className="zv-grille__menu-ligne">
787
+ <label className="zv-menu__item zv-grille__menu-case">
788
+ <Checkbox
789
+ checked={c.getIsVisible()}
790
+ disabled={!c.getCanHide()}
791
+ onChange={c.getToggleVisibilityHandler()}
792
+ />
793
+ <span>{nom}</span>
794
+ {cote && (
795
+ <Meta taille="sm">{cote === 'left' ? t.aGauche : t.aDroite}</Meta>
796
+ )}
797
+ </label>
798
+ {c.getCanPin() && (
799
+ <IconButton
800
+ icone="epingle"
801
+ taille="sm"
802
+ className="zv-grille__epingle"
803
+ aria-pressed={cote !== false}
804
+ label={
805
+ suivant === 'left'
806
+ ? t.epinglerGauche(nom)
807
+ : suivant === 'right'
808
+ ? t.epinglerDroite(nom)
809
+ : t.detacher(nom)
810
+ }
811
+ onClick={() => c.pin(suivant)}
812
+ />
813
+ )}
814
+ </div>
815
+ )
816
+ })}
817
+ </Menu>
818
+ )}
819
+ </SelectAnchor>
820
+ </div>
821
+ )
822
+ }
823
+
824
+ /* ─── Filtres de colonne ────────────────────────────────────────────── */
825
+
826
+ function FiltreColonne<T>({
827
+ colonne,
828
+ type,
829
+ t,
830
+ }: {
831
+ colonne: Column<T, unknown>
832
+ type: TypeFiltre
833
+ t: TextesGrille
834
+ }) {
835
+ const nom = libelleColonne(colonne)
836
+ const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
837
+
838
+ if (type === 'choix') return <FiltreChoix colonne={colonne} nom={nom} t={t} />
839
+ if (type === 'nombre' || type === 'date') {
840
+ return <FiltrePlage colonne={colonne} type={type} nom={nom} t={t} />
841
+ }
842
+ return (
843
+ <Input
844
+ className="zv-table__filtre"
845
+ value={filtre?.type === 'texte' ? filtre.valeur : ''}
846
+ onChange={(e) => colonne.setFilterValue({ type: 'texte', valeur: e.target.value })}
847
+ placeholder={t.filtrer}
848
+ aria-label={t.filtrerColonne(nom)}
849
+ />
850
+ )
851
+ }
852
+
853
+ /**
854
+ * Un filtre qui s'ouvre : le champ de la rangée RÉSUME la valeur, et ouvre
855
+ * un panneau (une liste de choix, une plage) sous lui.
856
+ *
857
+ * ⚠️ LE PANNEAU SORT DU CADRE, par un portail. `.zv-table` défile
858
+ * horizontalement, et `overflow-x: auto` force `overflow-y` : un panneau
859
+ * posé dans la cellule serait rogné par le bas du tableau, invisible sur un
860
+ * tableau de trois lignes. Il est rendu dans le <body> et placé sous son
861
+ * déclencheur, en coordonnées d'écran.
862
+ *
863
+ * ⚠️ AUCUN <select> NATIF ICI : sa liste est peinte par le SYSTÈME, hors
864
+ * charte (menu noir sous macOS sombre), et certaines apps l'interdisent.
865
+ */
866
+ function FiltreFlottant({
867
+ libelle,
868
+ resume,
869
+ vide,
870
+ icone,
871
+ role,
872
+ panneau: contenu,
873
+ }: {
874
+ libelle: string
875
+ resume: string
876
+ vide: boolean
877
+ icone: 'calendrier' | 'filtre' | 'chevron-bas'
878
+ role: 'dialog' | 'listbox'
879
+ /** Le contenu ; reçoit de quoi refermer le panneau. */
880
+ panneau: (fermer: () => void) => ReactNode
881
+ }) {
882
+ const [ouvert, setOuvert] = useState(false)
883
+ const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
884
+ const declencheur = useRef<HTMLButtonElement>(null)
885
+ const panneau = useRef<HTMLDivElement>(null)
886
+
887
+ const fermer = useCallback(() => {
888
+ setOuvert(false)
889
+ declencheur.current?.focus()
890
+ }, [])
891
+
892
+ // Placé AVANT l'affichage (layout effect) : sinon le panneau apparaît un
893
+ // instant en haut à gauche de la page.
894
+ useLayoutEffect(() => {
895
+ if (!ouvert) {
896
+ setPosition(null)
897
+ return
898
+ }
899
+ const placer = () => {
900
+ const r = declencheur.current?.getBoundingClientRect()
901
+ if (!r) return
902
+ // Le panneau est absolu dans son enveloppe fixe : c'est LUI qu'on
903
+ // mesure, l'enveloppe n'a pas de largeur.
904
+ const largeur = (panneau.current?.firstElementChild as HTMLElement | null)?.offsetWidth ?? 0
905
+ const vue = declencheur.current?.ownerDocument.defaultView
906
+ const droite = vue ? vue.innerWidth : r.right + largeur
907
+ // Le décalage de 5 px sous le déclencheur est celui de .zv-popover et
908
+ // de .zv-menu (top: calc(100% + 5px)) : l'enveloppe se pose au ras.
909
+ setPosition({ top: r.bottom, left: Math.max(0, Math.min(r.left, droite - largeur - 8)) })
910
+ }
911
+ placer()
912
+ const vue = declencheur.current?.ownerDocument.defaultView
913
+ vue?.addEventListener('resize', placer)
914
+ // `true` : le défilement du CADRE du tableau, pas seulement de la page.
915
+ vue?.addEventListener('scroll', placer, true)
916
+ return () => {
917
+ vue?.removeEventListener('resize', placer)
918
+ vue?.removeEventListener('scroll', placer, true)
919
+ }
920
+ }, [ouvert])
921
+
922
+ useEffect(() => {
923
+ if (!ouvert) return
924
+ const doc = declencheur.current?.ownerDocument ?? document
925
+ const dehors = (e: MouseEvent) => {
926
+ const cible = e.target as Node
927
+ if (!panneau.current?.contains(cible) && !declencheur.current?.contains(cible)) setOuvert(false)
928
+ }
929
+ const echap = (e: KeyboardEvent) => {
930
+ if (e.key === 'Escape') fermer()
931
+ }
932
+ doc.addEventListener('mousedown', dehors)
933
+ doc.addEventListener('keydown', echap)
934
+ return () => {
935
+ doc.removeEventListener('mousedown', dehors)
936
+ doc.removeEventListener('keydown', echap)
937
+ }
938
+ }, [ouvert, fermer])
939
+
940
+ // Le focus entre dans le panneau une fois PLACÉ : avant, il est en
941
+ // `visibility: hidden`, et le navigateur refuse le focus à un élément
942
+ // invisible. La frappe partait alors dans le vide. L'option choisie
943
+ // d'abord, sinon le premier champ ou la première option.
944
+ const place = position !== null
945
+ useEffect(() => {
946
+ if (!ouvert || !place) return
947
+ const p = panneau.current
948
+ ;(p?.querySelector<HTMLElement>('[aria-selected="true"]') ?? p?.querySelector<HTMLElement>('input, button'))?.focus()
949
+ }, [ouvert, place])
950
+
951
+ return (
952
+ <>
953
+ <button
954
+ ref={declencheur}
955
+ type="button"
956
+ className="zv-input zv-input--trigger zv-table__filtre"
957
+ data-vide={vide ? 'oui' : undefined}
958
+ aria-expanded={ouvert}
959
+ aria-haspopup={role}
960
+ aria-label={`${libelle} : ${resume}`}
961
+ onClick={() => setOuvert((o) => !o)}
962
+ >
963
+ <span className="zv-colhead__label">{resume}</span>
964
+ <Icon nom={icone} taille="sm" discret />
965
+ </button>
966
+ {ouvert &&
967
+ createPortal(
968
+ <div
969
+ ref={panneau}
970
+ className="zv-grille__flottant"
971
+ style={position ? { top: position.top, left: position.left } : { visibility: 'hidden' }}
972
+ >
973
+ {contenu(fermer)}
974
+ </div>,
975
+ declencheur.current?.ownerDocument.body ?? document.body,
976
+ )}
977
+ </>
978
+ )
979
+ }
980
+
981
+ /** Les flèches promènent le focus d'une option à l'autre, Origine et Fin
982
+ * vont aux extrémités : le parcours clavier d'une liste, que le <select>
983
+ * natif donnait et qu'une liste dessinée doit rendre. */
984
+ function parcourirListe(e: EvenementClavier<HTMLElement>) {
985
+ const options = [...e.currentTarget.querySelectorAll<HTMLElement>('[role="option"]')]
986
+ const i = options.indexOf(e.target as HTMLElement)
987
+ const vers =
988
+ e.key === 'ArrowDown' ? i + 1 : e.key === 'ArrowUp' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? options.length - 1 : null
989
+ if (vers === null) return
990
+ e.preventDefault()
991
+ options[Math.max(0, Math.min(options.length - 1, vers))]?.focus()
992
+ }
993
+
994
+ function FiltreChoix<T>({ colonne, nom, t }: { colonne: Column<T, unknown>; nom: string; t: TextesGrille }) {
995
+ const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
996
+ // Les valeurs du modèle FACETTÉ : celles que les autres filtres laissent
997
+ // encore passer. Une option qui ne rendrait aucune ligne n'est pas offerte.
998
+ const options = [...colonne.getFacetedUniqueValues().keys()]
999
+ .filter((v) => v != null && v !== '')
1000
+ .map(String)
1001
+ .sort((a, b) => a.localeCompare(b, 'fr'))
1002
+ const valeur = filtre?.type === 'choix' ? filtre.valeur : ''
1003
+ // L'option choisie reste dans la liste même si un autre filtre l'a fait
1004
+ // disparaître : sinon la liste afficherait « Tous » en mentant.
1005
+ if (valeur && !options.includes(valeur)) options.unshift(valeur)
1006
+
1007
+ return (
1008
+ <FiltreFlottant
1009
+ libelle={t.filtrerColonne(nom)}
1010
+ resume={valeur || t.tous}
1011
+ vide={!valeur}
1012
+ icone="chevron-bas"
1013
+ role="listbox"
1014
+ panneau={(fermer) => (
1015
+ <Menu role="listbox" aria-label={t.filtrerColonne(nom)} className="zv-grille__liste" onKeyDown={parcourirListe}>
1016
+ {['', ...options].map((o) => (
1017
+ <MenuItem
1018
+ key={o || '\u0000'}
1019
+ actif={o === valeur}
1020
+ onClick={() => {
1021
+ colonne.setFilterValue({ type: 'choix', valeur: o })
1022
+ fermer()
1023
+ }}
1024
+ >
1025
+ {o || t.tous}
1026
+ </MenuItem>
1027
+ ))}
1028
+ </Menu>
1029
+ )}
1030
+ />
1031
+ )
1032
+ }
1033
+
1034
+ function FiltrePlage<T>({
1035
+ colonne,
1036
+ type,
1037
+ nom,
1038
+ t,
1039
+ }: {
1040
+ colonne: Column<T, unknown>
1041
+ type: 'nombre' | 'date'
1042
+ nom: string
1043
+ t: TextesGrille
1044
+ }) {
1045
+ const idChamp = useId()
1046
+ const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
1047
+ const [a, b] =
1048
+ filtre?.type === 'nombre' ? [filtre.min, filtre.max] : filtre?.type === 'date' ? [filtre.du, filtre.au] : ['', '']
1049
+ const ecrire = (bas: string, haut: string) =>
1050
+ colonne.setFilterValue(
1051
+ type === 'nombre' ? { type, min: bas, max: haut } : { type, du: bas, au: haut },
1052
+ )
1053
+
1054
+ // Ce qui est TAPÉ, distinct de ce qui est APPLIQUÉ : une date en cours de
1055
+ // saisie s'affiche dans le champ sans filtrer le tableau. Resynchronisé
1056
+ // quand le filtre change d'ailleurs (« Réinitialiser la vue »).
1057
+ const [brouillon, setBrouillon] = useState<[string, string]>([a, b])
1058
+ useEffect(() => {
1059
+ setBrouillon([a, b])
1060
+ }, [a, b])
1061
+ const saisir = (bas: string, haut: string) => {
1062
+ setBrouillon([bas, haut])
1063
+ if (type === 'nombre' || (dateComplete(bas) && dateComplete(haut))) ecrire(bas, haut)
1064
+ }
1065
+ const resume = type === 'nombre' ? t.resumeNombres(a, b) : t.resumeDates(a, b)
1066
+ const vide = !a && !b
1067
+
1068
+ const champ = (
1069
+ valeur: string,
1070
+ changer: (v: string) => void,
1071
+ suffixe: string,
1072
+ bornes: { min?: string; max?: string },
1073
+ ) =>
1074
+ type === 'date' ? (
1075
+ <Input
1076
+ id={idChamp + suffixe}
1077
+ type="date"
1078
+ value={valeur}
1079
+ // Le calendrier natif grise ce qui rendrait la plage incohérente :
1080
+ // « Au » ne peut pas précéder « Du ».
1081
+ min={bornes.min || undefined}
1082
+ max={bornes.max || undefined}
1083
+ onChange={(e) => changer(e.target.value)}
1084
+ />
1085
+ ) : (
1086
+ <Input
1087
+ id={idChamp + suffixe}
1088
+ // Pas `type="number"` : il accepte « e », « + » et refuse la virgule
1089
+ // française selon la langue du système. Le clavier numérique vient
1090
+ // d'`inputMode`, le refus des lettres de `saisieNombre`.
1091
+ inputMode="decimal"
1092
+ autoComplete="off"
1093
+ value={valeur}
1094
+ onChange={(e) => changer(saisieNombre(e.target.value))}
1095
+ />
1096
+ )
1097
+
1098
+ return (
1099
+ <FiltreFlottant
1100
+ libelle={t.filtrerColonne(nom)}
1101
+ resume={resume}
1102
+ vide={vide}
1103
+ icone={type === 'date' ? 'calendrier' : 'filtre'}
1104
+ role="dialog"
1105
+ panneau={() => (
1106
+ <Popover className="zv-grille__plage" role="dialog" aria-label={t.filtrerColonne(nom)}>
1107
+ {type === 'date' && (
1108
+ <div className="zv-grille__periodes" role="group" aria-label={t.periodes}>
1109
+ {(['aujourdhui', 'septJours', 'trenteJours', 'ceMois', 'cetteAnnee'] as const).map((cle) => {
1110
+ const [du, au] = periodes(new Date())[cle]
1111
+ return (
1112
+ // `role="button"` : ces raccourcis sont des ACTIONS, pas
1113
+ // les options d'une liste (le rôle par défaut de MenuItem).
1114
+ <MenuItem
1115
+ key={cle}
1116
+ role="button"
1117
+ actif={a === du && b === au}
1118
+ aria-selected={undefined}
1119
+ aria-pressed={a === du && b === au}
1120
+ onClick={() => saisir(du, au)}
1121
+ >
1122
+ {t[cle]}
1123
+ </MenuItem>
1124
+ )
1125
+ })}
1126
+ </div>
1127
+ )}
1128
+ <div className="zv-grille__bornes">
1129
+ <Field label={type === 'date' ? t.du : t.min} htmlFor={idChamp + 'a'}>
1130
+ {champ(brouillon[0], (v) => saisir(v, brouillon[1]), 'a', { max: brouillon[1] })}
1131
+ </Field>
1132
+ <Field label={type === 'date' ? t.au : t.max} htmlFor={idChamp + 'b'}>
1133
+ {champ(brouillon[1], (v) => saisir(brouillon[0], v), 'b', { min: brouillon[0] })}
1134
+ </Field>
1135
+ </div>
1136
+ {!vide && (
1137
+ <Button variante="fantome" compact icone="fermer" onClick={() => saisir('', '')}>
1138
+ {t.effacer}
1139
+ </Button>
1140
+ )}
1141
+ </Popover>
1142
+ )}
1143
+ />
1144
+ )
1145
+ }
1146
+
1147
+ /* ─── La grille ──────────────────────────────────────────────────────── */
1148
+
1149
+ export function DataGrid<T>({
1150
+ tableId,
1151
+ data,
1152
+ columns,
1153
+ onRowClick,
1154
+ ligneActive,
1155
+ getRowId,
1156
+ selection = false,
1157
+ onSelectionChange,
1158
+ parPage = 25,
1159
+ recherche = true,
1160
+ filtres = true,
1161
+ exportCsv = true,
1162
+ vueInitiale,
1163
+ vide,
1164
+ outils,
1165
+ textes,
1166
+ className,
1167
+ }: DataGridProps<T>) {
1168
+ const t: TextesGrille = { ...TEXTES_GRILLE, ...textes }
1169
+ const ids = useMemo(
1170
+ () => columns.map((c) => idDeColonne(c)).filter((id): id is string => id != null),
1171
+ [columns],
1172
+ )
1173
+ const cleIds = ids.join('\u0000')
1174
+
1175
+ // La vue par défaut ne change qu'avec les colonnes : stable sinon, pour
1176
+ // que le hook ne la prenne pas pour une vue nouvelle à chaque rendu.
1177
+ const defaut = useMemo<VueGrille>(
1178
+ () => ({
1179
+ columnOrder: ids,
1180
+ columnVisibility: Object.fromEntries((vueInitiale?.masquees ?? []).map((id) => [id, false])),
1181
+ columnSizing: {},
1182
+ columnPinning: {
1183
+ left: vueInitiale?.epingleesGauche ?? [],
1184
+ right: vueInitiale?.epingleesDroite ?? [],
1185
+ },
1186
+ sorting: vueInitiale?.tri ?? [],
1187
+ pageSize: parPage || TAILLES_PAGE[0],
1188
+ }),
1189
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1190
+ [cleIds, JSON.stringify(vueInitiale ?? null), parPage],
1191
+ )
1192
+
1193
+ const reconcilier = useCallback(
1194
+ (lu: unknown, d: VueGrille) => reconcilierVue(lu, d, ids),
1195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1196
+ [cleIds],
1197
+ )
1198
+ const [vue, setVue, reinitialiserVue] = usePersistedState<VueGrille>(
1199
+ `table:${tableId}:v1`,
1200
+ defaut,
1201
+ reconcilier,
1202
+ )
1203
+
1204
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
1205
+ const [globalFilter, setGlobalFilter] = useState('')
1206
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
1207
+ const [pageIndex, setPageIndex] = useState(0)
1208
+
1209
+ const champ =
1210
+ <K extends keyof VueGrille>(k: K): OnChangeFn<VueGrille[K]> =>
1211
+ (u) =>
1212
+ setVue((v) => ({ ...v, [k]: appliquer(u, v[k]) }))
1213
+
1214
+ // La colonne de cases est épinglée à gauche, toujours, et n'entre pas dans
1215
+ // la vue enregistrée : elle dépend de la prop, pas de l'utilisateur.
1216
+ const pinning: ColumnPinningState = {
1217
+ left: selection ? [COLONNE_SELECTION, ...(vue.columnPinning.left ?? [])] : vue.columnPinning.left,
1218
+ right: vue.columnPinning.right,
1219
+ }
1220
+ const pagination: PaginationState = {
1221
+ pageIndex,
1222
+ pageSize: parPage === false ? Math.max(data.length, 1) : vue.pageSize,
1223
+ }
1224
+
1225
+ const colonnes = useMemo<ColumnDef<T, any>[]>(() => {
1226
+ const avecFiltre = columns.map((c) => (c.filterFn ? c : { ...c, filterFn: filtreGrille }))
1227
+ return selection ? [colonneSelection<T>(t), ...avecFiltre] : avecFiltre
1228
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1229
+ }, [columns, selection, t.toutSelectionner, t.selectionnerLigne])
1230
+
1231
+ const table = useReactTable<T>({
1232
+ data,
1233
+ columns: colonnes,
1234
+ getRowId,
1235
+ state: {
1236
+ columnOrder: vue.columnOrder,
1237
+ columnVisibility: vue.columnVisibility,
1238
+ columnSizing: vue.columnSizing,
1239
+ columnPinning: pinning,
1240
+ sorting: vue.sorting,
1241
+ columnFilters,
1242
+ globalFilter,
1243
+ rowSelection,
1244
+ pagination,
1245
+ },
1246
+ defaultColumn: { size: 150, minSize: 60 },
1247
+ onColumnOrderChange: champ('columnOrder'),
1248
+ onColumnVisibilityChange: champ('columnVisibility'),
1249
+ onColumnSizingChange: champ('columnSizing'),
1250
+ onSortingChange: champ('sorting'),
1251
+ onColumnPinningChange: (u) =>
1252
+ setVue((v) => {
1253
+ const actuel = {
1254
+ left: selection ? [COLONNE_SELECTION, ...(v.columnPinning.left ?? [])] : v.columnPinning.left,
1255
+ right: v.columnPinning.right,
1256
+ }
1257
+ const n = appliquer(u, actuel)
1258
+ return {
1259
+ ...v,
1260
+ columnPinning: {
1261
+ left: (n.left ?? []).filter((id) => id !== COLONNE_SELECTION),
1262
+ right: (n.right ?? []).filter((id) => id !== COLONNE_SELECTION),
1263
+ },
1264
+ }
1265
+ }),
1266
+ onColumnFiltersChange: setColumnFilters,
1267
+ onGlobalFilterChange: setGlobalFilter,
1268
+ onRowSelectionChange: setRowSelection,
1269
+ onPaginationChange: (u) => {
1270
+ const n = appliquer(u, pagination)
1271
+ setPageIndex(n.pageIndex)
1272
+ if (parPage !== false && n.pageSize !== vue.pageSize) {
1273
+ setVue((v) => ({ ...v, pageSize: n.pageSize }))
1274
+ }
1275
+ },
1276
+ enableRowSelection: selection,
1277
+ enableMultiSort: true,
1278
+ enableColumnResizing: true,
1279
+ columnResizeMode: 'onChange',
1280
+ enableColumnPinning: true,
1281
+ globalFilterFn: 'includesString',
1282
+ getCoreRowModel: getCoreRowModel(),
1283
+ getSortedRowModel: getSortedRowModel(),
1284
+ getFilteredRowModel: getFilteredRowModel(),
1285
+ getPaginationRowModel: getPaginationRowModel(),
1286
+ getFacetedRowModel: getFacetedRowModel(),
1287
+ getFacetedUniqueValues: getFacetedUniqueValues(),
1288
+ })
1289
+
1290
+ // La sélection se remonte en LIGNES, pas en identifiants : c'est ce que
1291
+ // l'app veut traiter (supprimer, exporter, assigner).
1292
+ const surSelection = useRef(onSelectionChange)
1293
+ surSelection.current = onSelectionChange
1294
+ useEffect(() => {
1295
+ surSelection.current?.(table.getSelectedRowModel().rows.map((r) => r.original))
1296
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1297
+ }, [rowSelection])
1298
+
1299
+ // Une page au-delà de la dernière (filtre resserré, données rechargées)
1300
+ // afficherait la ligne d'absence alors que des lignes existent.
1301
+ const nbPages = table.getPageCount()
1302
+ useEffect(() => {
1303
+ if (pageIndex > 0 && pageIndex >= nbPages) setPageIndex(Math.max(0, nbPages - 1))
1304
+ }, [pageIndex, nbPages])
1305
+
1306
+ const reinitialiser = () => {
1307
+ reinitialiserVue()
1308
+ setColumnFilters([])
1309
+ setGlobalFilter('')
1310
+ setPageIndex(0)
1311
+ }
1312
+
1313
+ /* Glisser-déposer des en-têtes. */
1314
+ const idDnd = useId()
1315
+ const capteurs = useSensors(
1316
+ // 4 px avant de saisir : sans ce seuil, un simple clic sur la poignée
1317
+ // démarre un déplacement.
1318
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
1319
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
1320
+ )
1321
+ const deplacables = table.getCenterVisibleLeafColumns().map((c) => c.id)
1322
+ const nomDe = (id: UniqueIdentifier) => {
1323
+ const c = table.getColumn(String(id))
1324
+ return c ? libelleColonne(c) : String(id)
1325
+ }
1326
+ const annonces: Announcements = {
1327
+ onDragStart: ({ active }) => t.dndSaisie(nomDe(active.id)),
1328
+ onDragOver: ({ active, over }) =>
1329
+ over ? t.dndSurvol(nomDe(active.id), nomDe(over.id)) : t.dndHors(nomDe(active.id)),
1330
+ onDragEnd: ({ active, over }) =>
1331
+ over ? t.dndPose(nomDe(active.id), nomDe(over.id)) : t.dndReposee(nomDe(active.id)),
1332
+ onDragCancel: ({ active }) => t.dndAnnule(nomDe(active.id)),
1333
+ }
1334
+ const finDeplacement = ({ active, over }: DragEndEvent) => {
1335
+ if (!over || active.id === over.id) return
1336
+ table.setColumnOrder((ordre) => {
1337
+ const depuis = ordre.indexOf(String(active.id))
1338
+ const vers = ordre.indexOf(String(over.id))
1339
+ return depuis < 0 || vers < 0 ? ordre : arrayMove(ordre, depuis, vers)
1340
+ })
1341
+ }
1342
+
1343
+ /* Rangée de filtres. La famille de chaque colonne se déduit des données
1344
+ * BRUTES (le modèle de base, pas le filtré) : sinon un filtre qui ne laisse
1345
+ * qu'une valeur ferait changer la colonne de famille sous le doigt. */
1346
+ const lignesBase = table.getCoreRowModel().flatRows
1347
+ const types = useMemo(() => {
1348
+ const sortie: Record<string, TypeFiltre | false> = {}
1349
+ for (const c of table.getAllLeafColumns()) {
1350
+ if (c.id === COLONNE_SELECTION) continue
1351
+ sortie[c.id] = typeDeFiltre(
1352
+ c.columnDef.meta,
1353
+ lignesBase.map((l) => l.getValue(c.id)),
1354
+ )
1355
+ }
1356
+ return sortie
1357
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1358
+ }, [lignesBase, colonnes])
1359
+ const filtrables = table.getVisibleLeafColumns().some((c) => c.getCanFilter() && types[c.id])
1360
+ const rangeeFiltres =
1361
+ filtres && filtrables ? (
1362
+ <div className="zv-table__head zv-table__head--filtres">
1363
+ {colonnesAffichees(table).map((c) => {
1364
+ const pin = epinglage(c, table)
1365
+ const type = c.getCanFilter() ? types[c.id] : false
1366
+ return (
1367
+ <div key={c.id} className={cx('zv-cell', pin.classes)} style={pin.style}>
1368
+ {type && <FiltreColonne colonne={c} type={type} t={t} />}
1369
+ </div>
1370
+ )
1371
+ })}
1372
+ </div>
1373
+ ) : null
1374
+
1375
+ const total = table.getPrePaginationRowModel().rows.length
1376
+ const { pageIndex: page, pageSize } = table.getState().pagination
1377
+ const debut = total === 0 ? 0 : page * pageSize + 1
1378
+ const fin = Math.min(total, (page + 1) * pageSize)
1379
+ const nbChoisies = Object.keys(rowSelection).length
1380
+ const filtre = columnFilters.length > 0 || globalFilter !== ''
1381
+
1382
+ return (
1383
+ <div className={cx('zv-grille', className)}>
1384
+ <div className="zv-grille__outils">
1385
+ {recherche ? (
1386
+ <Search
1387
+ value={globalFilter}
1388
+ onChange={(e) => setGlobalFilter(e.target.value)}
1389
+ placeholder={t.rechercher}
1390
+ aria-label={t.rechercher}
1391
+ />
1392
+ ) : (
1393
+ <span />
1394
+ )}
1395
+ <div className="zv-grille__actions">
1396
+ {selection && nbChoisies > 0 && (
1397
+ <Meta>{t.choisies(nbChoisies)}</Meta>
1398
+ )}
1399
+ {outils}
1400
+ <MenuColonnes table={table} t={t} />
1401
+ {exportCsv !== false && (
1402
+ <Button
1403
+ variante="fantome"
1404
+ compact
1405
+ icone="telecharger"
1406
+ onClick={() => telecharger(versCsv(table), typeof exportCsv === 'string' ? exportCsv : tableId)}
1407
+ >
1408
+ {t.exporter}
1409
+ </Button>
1410
+ )}
1411
+ <Button variante="fantome" compact icone="rembobiner" onClick={reinitialiser}>
1412
+ {t.reinitialiser}
1413
+ </Button>
1414
+ </div>
1415
+ </div>
1416
+
1417
+ <DndContext
1418
+ id={idDnd}
1419
+ sensors={capteurs}
1420
+ collisionDetection={closestCenter}
1421
+ modifiers={[axeHorizontal]}
1422
+ onDragEnd={finDeplacement}
1423
+ accessibility={{
1424
+ announcements: annonces,
1425
+ screenReaderInstructions: {
1426
+ draggable: t.dndInstructions,
1427
+ },
1428
+ }}
1429
+ >
1430
+ <SortableContext items={deplacables} strategy={horizontalListSortingStrategy}>
1431
+ <DataTable
1432
+ table={table}
1433
+ className="zv-table--grille"
1434
+ celluleEntete={(p) => <EnteteColonne {...p} t={t} />}
1435
+ sousEntete={rangeeFiltres}
1436
+ onRowClick={onRowClick}
1437
+ ligneActive={ligneActive}
1438
+ vide={
1439
+ vide ?? (
1440
+ <Empty>
1441
+ {data.length === 0 ? t.aucuneDonnee : filtre ? t.aucunResultat : t.aucuneLigne}
1442
+ </Empty>
1443
+ )
1444
+ }
1445
+ />
1446
+ </SortableContext>
1447
+ </DndContext>
1448
+
1449
+ {parPage !== false && (
1450
+ <Pager pied aria-label={t.pagination}>
1451
+ <Meta>{t.compte(debut, fin, total)}</Meta>
1452
+ <div className="zv-grille__pied">
1453
+ <SelectMenu
1454
+ className="zv-grille__taille"
1455
+ label={t.lignesParPage}
1456
+ valeur={String(pageSize)}
1457
+ options={TAILLES_PAGE.map((taille) => ({ valeur: String(taille), label: t.parPage(taille) }))}
1458
+ onChange={(v) => table.setPageSize(Number(v))}
1459
+ />
1460
+ <PagerArrow
1461
+ aria-label={t.pagePrecedente}
1462
+ desactive={!table.getCanPreviousPage()}
1463
+ onClick={() => table.getCanPreviousPage() && table.previousPage()}
1464
+ >
1465
+ ‹
1466
+ </PagerArrow>
1467
+ <Meta>{t.page(nbPages === 0 ? 0 : page + 1, nbPages)}</Meta>
1468
+ <PagerArrow
1469
+ aria-label={t.pageSuivante}
1470
+ desactive={!table.getCanNextPage()}
1471
+ onClick={() => table.getCanNextPage() && table.nextPage()}
1472
+ >
1473
+ ›
1474
+ </PagerArrow>
1475
+ </div>
1476
+ </Pager>
1477
+ )}
1478
+ </div>
1479
+ )
1480
+ }