@zevra/ui 0.42.0 → 0.43.0

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