@zevra/ui 0.43.1 → 0.44.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.
package/react/grille.tsx CHANGED
@@ -18,6 +18,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta
18
18
  import { createPortal } from 'react-dom'
19
19
  import type {
20
20
  CSSProperties,
21
+ DragEvent as EvenementGlisser,
21
22
  KeyboardEvent as EvenementClavier,
22
23
  PointerEvent as EvenementPointeur,
23
24
  ReactNode,
@@ -39,12 +40,12 @@ import {
39
40
  type FilterFn,
40
41
  type OnChangeFn,
41
42
  type PaginationState,
42
- type Row,
43
43
  type RowSelectionState,
44
44
  type SortingState,
45
45
  type Table as TableTanstack,
46
46
  type Updater,
47
47
  type VisibilityState,
48
+ type Row,
48
49
  } from '@tanstack/react-table'
49
50
  import {
50
51
  DndContext,
@@ -75,6 +76,7 @@ import { SelectMenu } from './select-menu.js'
75
76
  import { Menu, MenuItem, Pager, PagerArrow, Popover } from './navigation.js'
76
77
  import { Empty } from './feedback.js'
77
78
  import { Meta } from './data.js'
79
+ import { Mono } from './typography.js'
78
80
  import { cx } from './cx.js'
79
81
 
80
82
  export { usePersistedState }
@@ -94,6 +96,25 @@ export interface VueGrille {
94
96
  pageSize: number
95
97
  }
96
98
 
99
+ /** La sélection tenue par l'APP : les clés cochées lui appartiennent, elles
100
+ * survivent au changement de filtre, et ce sont elles que ses actions
101
+ * consomment. La case maîtresse porte sur les lignes AFFICHÉES, jamais sur
102
+ * ce qu'un filtre garde hors de l'écran, qui reste coché tel quel. */
103
+ export interface SelectionGrille<T> {
104
+ choisies: ReadonlySet<string>
105
+ onChoisir: (cles: Set<string>) => void
106
+ /** La clé retenue pour une ligne. Par défaut : son identifiant
107
+ * (`getRowId`). */
108
+ cle?: (ligne: T) => string
109
+ /** Ce que le lecteur d'écran annonce pour la case d'une ligne. */
110
+ label?: (ligne: T) => string
111
+ /** Les lignes qui ont une case ; les autres n'en ont pas, et la case
112
+ * maîtresse les ignore. */
113
+ selectionnable?: (ligne: T) => boolean
114
+ /** Cases inertes le temps d'une opération en cours. */
115
+ desactivee?: boolean
116
+ }
117
+
97
118
  /** Les tailles de page proposées au pied. */
98
119
  export const TAILLES_PAGE = [25, 50, 100] as const
99
120
 
@@ -114,6 +135,11 @@ export interface TextesGrille {
114
135
  min: string
115
136
  max: string
116
137
  effacer: string
138
+ effacerFiltres: string
139
+ toutAfficher: string
140
+ aucun: string
141
+ rechercherValeur: string
142
+ aucuneValeur: string
117
143
  /** Raccourcis de période du filtre de dates. */
118
144
  periodes: string
119
145
  aujourdhui: string
@@ -169,6 +195,11 @@ export const TEXTES_GRILLE: TextesGrille = {
169
195
  min: 'Min.',
170
196
  max: 'Max.',
171
197
  effacer: 'Effacer le filtre',
198
+ effacerFiltres: 'Effacer les filtres',
199
+ toutAfficher: 'Tout afficher',
200
+ aucun: 'Aucun',
201
+ rechercherValeur: 'Rechercher une valeur',
202
+ aucuneValeur: 'Aucune valeur.',
172
203
  periodes: 'Périodes',
173
204
  aujourdhui: "Aujourd'hui",
174
205
  septJours: '7 derniers jours',
@@ -216,8 +247,9 @@ export const TEXTES_GRILLE: TextesGrille = {
216
247
  export interface DataGridProps<T> {
217
248
  /** Identifiant STABLE du tableau : il nomme la vue enregistrée
218
249
  * (`table:<tableId>:v1`). Deux tableaux qui le partagent partagent leur
219
- * vue ; le changer fait oublier celle de tous les utilisateurs. */
220
- tableId: string
250
+ * vue ; le changer fait oublier celle de tous les utilisateurs. Sans
251
+ * lui, la vue vit le temps de la page et ne s'enregistre pas. */
252
+ tableId?: string
221
253
  data: T[]
222
254
  /** Colonnes TanStack. Chacune doit avoir un `id` ou un `accessorKey`
223
255
  * textuel : c'est lui que la vue enregistre. `size` est la largeur
@@ -234,13 +266,65 @@ export interface DataGridProps<T> {
234
266
  * données se rechargent : par défaut c'est l'INDEX, et une ligne cochée
235
267
  * deviendrait une autre ligne après un tri côté serveur. */
236
268
  getRowId?: (ligne: T, index: number) => string
237
- /** Une colonne de cases, épinglée à gauche. */
238
- selection?: boolean
269
+ /** Une colonne de cases, épinglée à gauche. `true` : la grille tient les
270
+ * cases et remonte les lignes par `onSelectionChange`. Un objet : l'APP
271
+ * tient l'ensemble des clés cochées (elles survivent à un filtre, à une
272
+ * page, à un rechargement), voir `SelectionGrille`. */
273
+ selection?: boolean | SelectionGrille<T>
239
274
  onSelectionChange?: (lignes: T[]) => void
275
+ /** Tri délégué au SERVEUR : sur une liste paginée côté serveur, trier dans
276
+ * le navigateur ne trierait que la page affichée. La grille n'ordonne
277
+ * plus rien, elle dit seulement quel tri est demandé. */
278
+ triServeur?: { tri: SortingState; onTri: (tri: SortingState) => void }
279
+ /** Clic droit sur une ligne : l'app ouvre son menu contextuel. */
280
+ onRowContextMenu?: (ligne: T, x: number, y: number) => void
281
+ /** Double clic sur une ligne : « ouvrir pour de bon ». */
282
+ onRowDoubleClick?: (ligne: T) => void
283
+ /** La ligne se glisse (glisser-déposer natif) : l'app remplit le
284
+ * `dataTransfer`. */
285
+ onRowDragStart?: (ligne: T, e: EvenementGlisser<HTMLDivElement>) => void
286
+ /** Une ligne de SAISIE en tête du corps : un contenu par identifiant de
287
+ * colonne. Elle suit l'ordre, la visibilité et l'épinglage des colonnes. */
288
+ ligneSaisie?: Partial<Record<string, ReactNode>>
289
+ /** Des intitulés intercalés quand la clé change d'une ligne à l'autre (une
290
+ * année, un mois). N'a de sens que si le tri suit cette clé. */
291
+ groupe?: { de: (ligne: T) => string; rendu?: (cle: string, lignes: T[]) => ReactNode }
292
+ /** Des lignes qui restent en tête QUEL QUE SOIT le tri : le rang 0 d'abord,
293
+ * puis 1, etc., chacun trié comme le reste (les pièces épinglées). */
294
+ rang?: (ligne: T) => number
295
+ /** Une rangée de totaux sous les lignes, calculée sur les lignes
296
+ * AFFICHÉES (après recherche et filtres) : un contenu par colonne. */
297
+ pied?: (lignes: T[]) => Partial<Record<string, ReactNode>>
298
+ /** Rangées resserrées : un tableau secondaire dans une fiche. */
299
+ dense?: boolean
300
+ /** La grille REMPLIT son parent (colonne flex à hauteur bornée) : seules
301
+ * les lignes défilent, la barre, les intitulés et les filtres restent. */
302
+ remplir?: boolean
303
+ /** Sous les lignes, dans la zone qui défile : le déclencheur d'une liste
304
+ * infinie, qui charge la suite en entrant dans l'écran. */
305
+ apresLignes?: ReactNode
306
+ /** Enveloppe le CADRE du tableau seul (pas la barre) : un voile de
307
+ * rechargement qui laisse la recherche et les filtres utilisables. */
308
+ envelopperTableau?: (tableau: ReactNode) => ReactNode
309
+ /** La barre d'outils (recherche, colonnes, export). Par défaut : oui.
310
+ * Sans elle, les filtres de colonne restent. */
311
+ barre?: boolean
312
+ /** Un champ de recherche fourni par l'app, à la place de celui de la
313
+ * grille : une recherche faite par le serveur, par exemple. */
314
+ champRecherche?: ReactNode
315
+ /** Visibilité des colonnes TENUE PAR L'APP, quand son bouton « Colonnes »
316
+ * vit hors de la grille. Sinon, la vue enregistrée la tient. */
317
+ visibilite?: VisibilityState
318
+ onVisibilite?: (u: Updater<VisibilityState>) => void
319
+ /** Les lignes affichées (après recherche, filtres et tri) et si un filtre
320
+ * est actif : ce qu'un téléchargement de l'app doit reprendre. Appelé
321
+ * quand leur ensemble change, pas à chaque rendu. */
322
+ onLignesAffichees?: (lignes: T[], filtre: boolean) => void
240
323
  /** Lignes par page (25, 50 ou 100), ou `false` pour tout afficher. */
241
324
  parPage?: (typeof TAILLES_PAGE)[number] | false
242
- /** Champ de recherche sur toutes les colonnes. Par défaut : oui. */
243
- recherche?: boolean
325
+ /** Champ de recherche sur toutes les colonnes. Par défaut : oui. Une
326
+ * chaîne en est le texte d'invite. */
327
+ recherche?: boolean | string
244
328
  /** Rangée de filtres sous les intitulés. Par défaut : oui. */
245
329
  filtres?: boolean
246
330
  /** Bouton d'export CSV des lignes filtrées et triées, colonnes visibles.
@@ -347,7 +431,7 @@ export type TypeFiltre = 'texte' | 'nombre' | 'date' | 'choix'
347
431
  /** La valeur d'un filtre de colonne, telle que TanStack la tient. */
348
432
  export type ValeurFiltre =
349
433
  | { type: 'texte'; valeur: string }
350
- | { type: 'choix'; valeur: string }
434
+ | { type: 'choix'; valeurs: string[] }
351
435
  | { type: 'nombre'; min: string; max: string }
352
436
  | { type: 'date'; du: string; au: string }
353
437
 
@@ -398,15 +482,23 @@ const nombre = (s: string): number | null => {
398
482
  return Number.isFinite(n) ? n : null
399
483
  }
400
484
 
485
+ /** Sans casse ni accents : « decision » trouve « Décision ». */
486
+ export function normaliser(v: unknown): string {
487
+ return String(v ?? '')
488
+ .toLowerCase()
489
+ .normalize('NFD')
490
+ .replace(/[\u0300-\u036f]/g, '')
491
+ }
492
+
401
493
  /** Le filtre de toutes les colonnes de la grille : il lit la FORME de la
402
494
  * valeur, pas la colonne. Bornes comprises. */
403
495
  export const filtreGrille: FilterFn<any> = (ligne, colonne, filtre: ValeurFiltre) => {
404
496
  const brut = ligne.getValue(colonne)
405
497
  switch (filtre?.type) {
406
498
  case 'texte':
407
- return String(brut ?? '').toLowerCase().includes(filtre.valeur.trim().toLowerCase())
499
+ return normaliser(brut).includes(normaliser(filtre.valeur.trim()))
408
500
  case 'choix':
409
- return String(brut ?? '') === filtre.valeur
501
+ return filtre.valeurs.includes(String(brut ?? ''))
410
502
  case 'nombre': {
411
503
  const v = typeof brut === 'number' ? brut : nombre(String(brut ?? ''))
412
504
  const min = nombre(filtre.min)
@@ -426,7 +518,7 @@ export const filtreGrille: FilterFn<any> = (ligne, colonne, filtre: ValeurFiltre
426
518
  filtreGrille.autoRemove = (f: ValeurFiltre | undefined) =>
427
519
  !f ||
428
520
  (f.type === 'texte' && f.valeur.trim() === '') ||
429
- (f.type === 'choix' && f.valeur === '') ||
521
+ (f.type === 'choix' && f.valeurs.length === 0) ||
430
522
  (f.type === 'nombre' && f.min === '' && f.max === '') ||
431
523
  (f.type === 'date' && f.du === '' && f.au === '')
432
524
 
@@ -685,28 +777,34 @@ function PoigneeLargeur<T>({
685
777
 
686
778
  /* ─── Cases de sélection ─────────────────────────────────────────────── */
687
779
 
688
- function CaseTout<T>({ table, t }: { table: TableTanstack<T>; t: TextesGrille }) {
780
+ type OptionsCases<T> = { current: SelectionGrille<T> | null }
781
+
782
+ function CaseTout<T>({ table, t, options }: { table: TableTanstack<T>; t: TextesGrille; options: OptionsCases<T> }) {
689
783
  // Une enveloppe plutôt qu'une ref sur la case : `Checkbox` ne transmet
690
784
  // pas de ref sous React 18, que le paquet accepte encore.
691
785
  const ref = useRef<HTMLSpanElement>(null)
692
786
  const partiel = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
693
787
  // `indeterminate` n'a pas d'attribut HTML : seule la propriété DOM existe.
788
+ // Sans elle, « 3 sur 40 cochées » ressemble à « aucune ».
694
789
  useEffect(() => {
695
790
  const caseTout = ref.current?.querySelector('input')
696
791
  if (caseTout) caseTout.indeterminate = partiel
697
792
  }, [partiel])
793
+ const aucune = !table.getPrePaginationRowModel().rows.some((r) => r.getCanSelect())
698
794
  return (
699
795
  <span ref={ref} className="zv-cell--case">
700
- <Checkbox
701
- checked={table.getIsAllRowsSelected()}
702
- onChange={table.getToggleAllRowsSelectedHandler()}
703
- aria-label={t.toutSelectionner}
704
- />
796
+ <Checkbox
797
+ checked={table.getIsAllRowsSelected()}
798
+ disabled={aucune || options.current?.desactivee}
799
+ onChange={table.getToggleAllRowsSelectedHandler()}
800
+ aria-label={t.toutSelectionner}
801
+ title={t.toutSelectionner}
802
+ />
705
803
  </span>
706
804
  )
707
805
  }
708
806
 
709
- function colonneSelection<T>(t: TextesGrille): ColumnDef<T, any> {
807
+ function colonneSelection<T>(t: TextesGrille, options: OptionsCases<T>): ColumnDef<T, any> {
710
808
  return {
711
809
  id: COLONNE_SELECTION,
712
810
  size: 40,
@@ -717,19 +815,22 @@ function colonneSelection<T>(t: TextesGrille): ColumnDef<T, any> {
717
815
  enableColumnFilter: false,
718
816
  enableGlobalFilter: false,
719
817
  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
- ),
818
+ header: ({ table }) => <CaseTout table={table} t={t} options={options} />,
819
+ // Une ligne qui ne se sélectionne pas n'a PAS de case : une case grisée
820
+ // promettrait une action qui n'existe pas pour elle.
821
+ cell: ({ row }: { row: Row<T> }) =>
822
+ row.getCanSelect() ? (
823
+ <Checkbox
824
+ checked={row.getIsSelected()}
825
+ disabled={options.current?.desactivee}
826
+ onChange={row.getToggleSelectedHandler()}
827
+ // La case vit dans une ligne cliquable : sans ces deux arrêts,
828
+ // cocher ouvrirait aussi la ligne, et Espace l'ouvrirait au clavier.
829
+ onClick={(e) => e.stopPropagation()}
830
+ onKeyDown={(e) => e.stopPropagation()}
831
+ aria-label={options.current?.label?.(row.original) ?? t.selectionnerLigne}
832
+ />
833
+ ) : null,
733
834
  meta: { classe: 'zv-cell--case' },
734
835
  }
735
836
  }
@@ -778,6 +879,15 @@ function MenuColonnes<T>({ table, t }: { table: TableTanstack<T>; t: TextesGrill
778
879
  </Button>
779
880
  {ouvert && (
780
881
  <Menu role="group" aria-label={t.colonnesAffichees} className="zv-grille__menu">
882
+ {colonnes.some((c) => !c.getIsVisible()) && (
883
+ <Button
884
+ variante="fantome"
885
+ compact
886
+ onClick={() => table.setColumnVisibility((v) => Object.fromEntries(Object.keys(v).map((k) => [k, true])))}
887
+ >
888
+ {t.toutAfficher}
889
+ </Button>
890
+ )}
781
891
  {colonnes.map((c) => {
782
892
  const nom = libelleColonne(c)
783
893
  const cote = c.getIsPinned()
@@ -978,59 +1088,94 @@ function FiltreFlottant({
978
1088
  )
979
1089
  }
980
1090
 
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
1091
  function FiltreChoix<T>({ colonne, nom, t }: { colonne: Column<T, unknown>; nom: string; t: TextesGrille }) {
995
1092
  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()]
1093
+ const choisies = filtre?.type === 'choix' ? filtre.valeurs : []
1094
+ // Les valeurs du modèle FACETTÉ, avec leur nombre de lignes : celles que
1095
+ // les autres filtres laissent encore passer. Une option qui ne rendrait
1096
+ // aucune ligne n'est pas offerte, sauf si elle est déjà cochée : sinon
1097
+ // la liste mentirait sur ce qui filtre.
1098
+ const comptes = colonne.getFacetedUniqueValues()
1099
+ const options = [...comptes.keys()]
999
1100
  .filter((v) => v != null && v !== '')
1000
1101
  .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)
1102
+ .sort((a, b) => a.localeCompare(b, 'fr', { numeric: true }))
1103
+ for (const c of choisies) if (!options.includes(c)) options.unshift(c)
1104
+ const ecrire = (valeurs: string[]) => colonne.setFilterValue({ type: 'choix', valeurs })
1105
+ const resume = choisies.length === 0 ? t.tous : choisies.length === 1 ? (choisies[0] ?? t.tous) : `${choisies.length} / ${options.length}`
1006
1106
 
1007
1107
  return (
1008
1108
  <FiltreFlottant
1009
1109
  libelle={t.filtrerColonne(nom)}
1010
- resume={valeur || t.tous}
1011
- vide={!valeur}
1110
+ resume={resume}
1111
+ vide={choisies.length === 0}
1012
1112
  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
- )}
1113
+ role="dialog"
1114
+ panneau={() => <ListeChoix options={options} comptes={comptes} choisies={choisies} ecrire={ecrire} nom={nom} t={t} />}
1030
1115
  />
1031
1116
  )
1032
1117
  }
1033
1118
 
1119
+ /** Plusieurs valeurs à la fois (« Acte » OU « Contrat ») : des cases, avec
1120
+ * le nombre de lignes de chacune, une recherche au-delà de huit options,
1121
+ * et « Tout » / « Aucun » sur ce que la recherche montre. */
1122
+ function ListeChoix({
1123
+ options,
1124
+ comptes,
1125
+ choisies,
1126
+ ecrire,
1127
+ nom,
1128
+ t,
1129
+ }: {
1130
+ options: string[]
1131
+ comptes: Map<unknown, number>
1132
+ choisies: string[]
1133
+ ecrire: (valeurs: string[]) => void
1134
+ nom: string
1135
+ t: TextesGrille
1136
+ }) {
1137
+ const [q, setQ] = useState('')
1138
+ const vues = q.trim() ? options.filter((o) => normaliser(o).includes(normaliser(q.trim()))) : options
1139
+ return (
1140
+ <Popover className="zv-grille__plage" role="dialog" aria-label={t.filtrerColonne(nom)}>
1141
+ {options.length > 8 && (
1142
+ <Search value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.rechercherValeur} aria-label={t.rechercherValeur} />
1143
+ )}
1144
+ <div className="zv-grille__menu-ligne">
1145
+ <Button variante="fantome" compact onClick={() => ecrire([...new Set([...choisies, ...vues])])}>
1146
+ {t.tous}
1147
+ </Button>
1148
+ <Button variante="fantome" compact onClick={() => ecrire(choisies.filter((c) => !vues.includes(c)))}>
1149
+ {t.aucun}
1150
+ </Button>
1151
+ </div>
1152
+ <div className="zv-grille__liste" role="group" aria-label={t.filtrerColonne(nom)}>
1153
+ {vues.map((o) => {
1154
+ const pris = choisies.includes(o)
1155
+ return (
1156
+ <label key={o} className="zv-menu__item zv-grille__menu-case">
1157
+ <Checkbox
1158
+ checked={pris}
1159
+ onChange={() => ecrire(pris ? choisies.filter((c) => c !== o) : [...choisies, o])}
1160
+ />
1161
+ <span className="zv-colhead__label">{o}</span>
1162
+ <Meta taille="sm" discret className="zv-grille__compte">
1163
+ {comptes.get(o) ?? 0}
1164
+ </Meta>
1165
+ </label>
1166
+ )
1167
+ })}
1168
+ {vues.length === 0 && <Meta taille="sm">{t.aucuneValeur}</Meta>}
1169
+ </div>
1170
+ {choisies.length > 0 && (
1171
+ <Button variante="fantome" compact icone="fermer" onClick={() => ecrire([])}>
1172
+ {t.effacer}
1173
+ </Button>
1174
+ )}
1175
+ </Popover>
1176
+ )
1177
+ }
1178
+
1034
1179
  function FiltrePlage<T>({
1035
1180
  colonne,
1036
1181
  type,
@@ -1155,6 +1300,23 @@ export function DataGrid<T>({
1155
1300
  getRowId,
1156
1301
  selection = false,
1157
1302
  onSelectionChange,
1303
+ triServeur,
1304
+ onRowContextMenu,
1305
+ onRowDoubleClick,
1306
+ onRowDragStart,
1307
+ ligneSaisie,
1308
+ groupe,
1309
+ rang,
1310
+ pied,
1311
+ dense,
1312
+ remplir,
1313
+ apresLignes,
1314
+ envelopperTableau,
1315
+ barre = true,
1316
+ champRecherche,
1317
+ visibilite,
1318
+ onVisibilite,
1319
+ onLignesAffichees,
1158
1320
  parPage = 25,
1159
1321
  recherche = true,
1160
1322
  filtres = true,
@@ -1196,14 +1358,14 @@ export function DataGrid<T>({
1196
1358
  [cleIds],
1197
1359
  )
1198
1360
  const [vue, setVue, reinitialiserVue] = usePersistedState<VueGrille>(
1199
- `table:${tableId}:v1`,
1361
+ tableId ? `table:${tableId}:v1` : null,
1200
1362
  defaut,
1201
1363
  reconcilier,
1202
1364
  )
1203
1365
 
1204
1366
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
1205
1367
  const [globalFilter, setGlobalFilter] = useState('')
1206
- const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
1368
+ const [selectionInterne, setSelectionInterne] = useState<RowSelectionState>({})
1207
1369
  const [pageIndex, setPageIndex] = useState(0)
1208
1370
 
1209
1371
  const champ =
@@ -1211,10 +1373,44 @@ export function DataGrid<T>({
1211
1373
  (u) =>
1212
1374
  setVue((v) => ({ ...v, [k]: appliquer(u, v[k]) }))
1213
1375
 
1376
+ /* Sélection. Tenue par la grille (`true`) ou par l'app (un objet) : dans le
1377
+ * second cas, l'état de TanStack se DÉDUIT des clés de l'app à chaque rendu,
1378
+ * et chaque changement lui est rendu en clés. */
1379
+ const avecCases = selection !== false
1380
+ const externe = typeof selection === 'object' ? selection : null
1381
+ const idLigne = (l: T, i: number) => getRowId?.(l, i) ?? String(i)
1382
+ const cleDe = (l: T, i: number) => externe?.cle?.(l) ?? idLigne(l, i)
1383
+ const rowSelection: RowSelectionState = externe
1384
+ ? Object.fromEntries(
1385
+ // Une ligne sans case ne se peint pas cochée, même si sa clé est
1386
+ // dans l'ensemble de l'app : on la verrait choisie sans pouvoir
1387
+ // la décocher.
1388
+ data.flatMap((l, i) =>
1389
+ externe.choisies.has(cleDe(l, i)) && externe.selectionnable?.(l) !== false
1390
+ ? [[idLigne(l, i), true]]
1391
+ : [],
1392
+ ),
1393
+ )
1394
+ : selectionInterne
1395
+ const surSelectionLignes: OnChangeFn<RowSelectionState> = (u) => {
1396
+ if (!externe) return setSelectionInterne(u)
1397
+ const n = appliquer(u, rowSelection)
1398
+ const suite = new Set(externe.choisies)
1399
+ // Seules les clés des lignes PRÉSENTES changent : une ligne cochée puis
1400
+ // filtrée hors de l'écran, ou sur une autre page, reste cochée.
1401
+ data.forEach((l, i) => {
1402
+ if (externe.selectionnable?.(l) === false) return
1403
+ const cle = cleDe(l, i)
1404
+ if (n[idLigne(l, i)]) suite.add(cle)
1405
+ else suite.delete(cle)
1406
+ })
1407
+ externe.onChoisir(suite)
1408
+ }
1409
+
1214
1410
  // La colonne de cases est épinglée à gauche, toujours, et n'entre pas dans
1215
1411
  // la vue enregistrée : elle dépend de la prop, pas de l'utilisateur.
1216
1412
  const pinning: ColumnPinningState = {
1217
- left: selection ? [COLONNE_SELECTION, ...(vue.columnPinning.left ?? [])] : vue.columnPinning.left,
1413
+ left: avecCases ? [COLONNE_SELECTION, ...(vue.columnPinning.left ?? [])] : vue.columnPinning.left,
1218
1414
  right: vue.columnPinning.right,
1219
1415
  }
1220
1416
  const pagination: PaginationState = {
@@ -1222,11 +1418,17 @@ export function DataGrid<T>({
1222
1418
  pageSize: parPage === false ? Math.max(data.length, 1) : vue.pageSize,
1223
1419
  }
1224
1420
 
1421
+ // L'option de sélection est lue AU RENDU par les cases : les colonnes n'ont
1422
+ // pas à se recalculer à chaque case cochée.
1423
+ const optionsCases = useRef(externe)
1424
+ optionsCases.current = externe
1225
1425
  const colonnes = useMemo<ColumnDef<T, any>[]>(() => {
1226
1426
  const avecFiltre = columns.map((c) => (c.filterFn ? c : { ...c, filterFn: filtreGrille }))
1227
- return selection ? [colonneSelection<T>(t), ...avecFiltre] : avecFiltre
1427
+ return avecCases ? [colonneSelection<T>(t, optionsCases), ...avecFiltre] : avecFiltre
1228
1428
  // eslint-disable-next-line react-hooks/exhaustive-deps
1229
- }, [columns, selection, t.toutSelectionner, t.selectionnerLigne])
1429
+ }, [columns, avecCases, t.toutSelectionner, t.selectionnerLigne])
1430
+
1431
+ const visibiliteCourante = visibilite ?? vue.columnVisibility
1230
1432
 
1231
1433
  const table = useReactTable<T>({
1232
1434
  data,
@@ -1234,10 +1436,10 @@ export function DataGrid<T>({
1234
1436
  getRowId,
1235
1437
  state: {
1236
1438
  columnOrder: vue.columnOrder,
1237
- columnVisibility: vue.columnVisibility,
1439
+ columnVisibility: visibiliteCourante,
1238
1440
  columnSizing: vue.columnSizing,
1239
1441
  columnPinning: pinning,
1240
- sorting: vue.sorting,
1442
+ sorting: triServeur ? triServeur.tri : vue.sorting,
1241
1443
  columnFilters,
1242
1444
  globalFilter,
1243
1445
  rowSelection,
@@ -1245,13 +1447,18 @@ export function DataGrid<T>({
1245
1447
  },
1246
1448
  defaultColumn: { size: 150, minSize: 60 },
1247
1449
  onColumnOrderChange: champ('columnOrder'),
1248
- onColumnVisibilityChange: champ('columnVisibility'),
1450
+ onColumnVisibilityChange: onVisibilite
1451
+ ? (u) => onVisibilite((v) => appliquer(u, v))
1452
+ : champ('columnVisibility'),
1249
1453
  onColumnSizingChange: champ('columnSizing'),
1250
- onSortingChange: champ('sorting'),
1454
+ onSortingChange: triServeur
1455
+ ? (u) => triServeur.onTri(appliquer(u, triServeur.tri))
1456
+ : champ('sorting'),
1457
+ manualSorting: Boolean(triServeur),
1251
1458
  onColumnPinningChange: (u) =>
1252
1459
  setVue((v) => {
1253
1460
  const actuel = {
1254
- left: selection ? [COLONNE_SELECTION, ...(v.columnPinning.left ?? [])] : v.columnPinning.left,
1461
+ left: avecCases ? [COLONNE_SELECTION, ...(v.columnPinning.left ?? [])] : v.columnPinning.left,
1255
1462
  right: v.columnPinning.right,
1256
1463
  }
1257
1464
  const n = appliquer(u, actuel)
@@ -1265,7 +1472,7 @@ export function DataGrid<T>({
1265
1472
  }),
1266
1473
  onColumnFiltersChange: setColumnFilters,
1267
1474
  onGlobalFilterChange: setGlobalFilter,
1268
- onRowSelectionChange: setRowSelection,
1475
+ onRowSelectionChange: surSelectionLignes,
1269
1476
  onPaginationChange: (u) => {
1270
1477
  const n = appliquer(u, pagination)
1271
1478
  setPageIndex(n.pageIndex)
@@ -1273,14 +1480,17 @@ export function DataGrid<T>({
1273
1480
  setVue((v) => ({ ...v, pageSize: n.pageSize }))
1274
1481
  }
1275
1482
  },
1276
- enableRowSelection: selection,
1483
+ enableRowSelection: externe?.selectionnable
1484
+ ? (r: Row<T>) => externe.selectionnable!(r.original)
1485
+ : avecCases,
1277
1486
  enableMultiSort: true,
1278
1487
  enableColumnResizing: true,
1279
1488
  columnResizeMode: 'onChange',
1280
1489
  enableColumnPinning: true,
1281
- globalFilterFn: 'includesString',
1490
+ // Sans casse ni accents, comme les filtres de colonne.
1491
+ globalFilterFn: (ligne, colonne, q) => normaliser(ligne.getValue(colonne)).includes(normaliser(String(q).trim())),
1282
1492
  getCoreRowModel: getCoreRowModel(),
1283
- getSortedRowModel: getSortedRowModel(),
1493
+ getSortedRowModel: triServeur ? undefined : getSortedRowModel(),
1284
1494
  getFilteredRowModel: getFilteredRowModel(),
1285
1495
  getPaginationRowModel: getPaginationRowModel(),
1286
1496
  getFacetedRowModel: getFacetedRowModel(),
@@ -1292,9 +1502,10 @@ export function DataGrid<T>({
1292
1502
  const surSelection = useRef(onSelectionChange)
1293
1503
  surSelection.current = onSelectionChange
1294
1504
  useEffect(() => {
1505
+ if (externe) return
1295
1506
  surSelection.current?.(table.getSelectedRowModel().rows.map((r) => r.original))
1296
1507
  // eslint-disable-next-line react-hooks/exhaustive-deps
1297
- }, [rowSelection])
1508
+ }, [selectionInterne])
1298
1509
 
1299
1510
  // Une page au-delà de la dernière (filtre resserré, données rechargées)
1300
1511
  // afficherait la ligne d'absence alors que des lignes existent.
@@ -1303,18 +1514,22 @@ export function DataGrid<T>({
1303
1514
  if (pageIndex > 0 && pageIndex >= nbPages) setPageIndex(Math.max(0, nbPages - 1))
1304
1515
  }, [pageIndex, nbPages])
1305
1516
 
1306
- const reinitialiser = () => {
1307
- reinitialiserVue()
1517
+ const effacerFiltres = () => {
1308
1518
  setColumnFilters([])
1309
1519
  setGlobalFilter('')
1520
+ }
1521
+ const reinitialiser = () => {
1522
+ reinitialiserVue()
1523
+ if (onVisibilite) onVisibilite(() => defaut.columnVisibility)
1524
+ effacerFiltres()
1310
1525
  setPageIndex(0)
1311
1526
  }
1312
1527
 
1313
1528
  /* Glisser-déposer des en-têtes. */
1314
1529
  const idDnd = useId()
1315
1530
  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.
1531
+ // 4 px avant de saisir : sans ce seuil, un simple clic sur l'en-tête
1532
+ // démarre un déplacement au lieu de trier.
1318
1533
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
1319
1534
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
1320
1535
  )
@@ -1357,94 +1572,170 @@ export function DataGrid<T>({
1357
1572
  // eslint-disable-next-line react-hooks/exhaustive-deps
1358
1573
  }, [lignesBase, colonnes])
1359
1574
  const filtrables = table.getVisibleLeafColumns().some((c) => c.getCanFilter() && types[c.id])
1575
+
1576
+ /** Une rangée qui suit les colonnes affichées (ordre, épinglage) : les
1577
+ * filtres, la ligne de saisie, le pied. */
1578
+ const rangee = (classe: string, contenu: (c: Column<T, unknown>) => ReactNode) => (
1579
+ <div className={classe}>
1580
+ {colonnesAffichees(table).map((c) => {
1581
+ const pin = epinglage(c, table)
1582
+ const registre = c.columnDef.meta?.registre
1583
+ return (
1584
+ <div
1585
+ key={c.id}
1586
+ className={cx('zv-cell', registre === 'num' && 'zv-cell--num', registre === 'nom' && 'zv-cell--name', pin.classes)}
1587
+ style={pin.style}
1588
+ >
1589
+ {contenu(c)}
1590
+ </div>
1591
+ )
1592
+ })}
1593
+ </div>
1594
+ )
1360
1595
  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)
1596
+ filtres && filtrables
1597
+ ? rangee('zv-table__head zv-table__head--filtres', (c) => {
1365
1598
  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
1599
+ return type ? <FiltreColonne colonne={c} type={type} t={t} /> : null
1600
+ })
1601
+ : null
1602
+
1603
+ /* Lignes : le rang (des lignes gardées en tête), puis les intitulés de
1604
+ * groupe et le pied, calculés sur ce qui est AFFICHÉ. */
1605
+ const lignesTriees = table.getRowModel().rows
1606
+ const lignesRendues = useMemo(() => {
1607
+ if (!rang) return lignesTriees
1608
+ return lignesTriees
1609
+ .map((r, i) => ({ r, i, k: rang(r.original) }))
1610
+ .sort((a, b) => a.k - b.k || a.i - b.i)
1611
+ .map((x) => x.r)
1612
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1613
+ }, [lignesTriees, rang])
1614
+ const affichees = table.getPrePaginationRowModel().rows.map((r) => r.original)
1615
+ const filtre = columnFilters.length > 0 || globalFilter.trim() !== ''
1616
+
1617
+ const surAffichees = useRef(onLignesAffichees)
1618
+ surAffichees.current = onLignesAffichees
1619
+ const signatureAffichees = `${filtre}|${lignesRendues.map((r) => r.id).join('\u0001')}`
1620
+ useEffect(() => {
1621
+ surAffichees.current?.(lignesRendues.map((r) => r.original), filtre)
1622
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1623
+ }, [signatureAffichees])
1624
+
1625
+ const intercalaire = groupe
1626
+ ? (ligne: Row<T>, precedente: Row<T> | undefined) => {
1627
+ const cle = groupe.de(ligne.original)
1628
+ if (precedente && groupe.de(precedente.original) === cle) return null
1629
+ const membres = lignesRendues.filter((r) => groupe.de(r.original) === cle).map((r) => r.original)
1630
+ return (
1631
+ <div className="zv-row zv-row--groupe">
1632
+ {groupe.rendu ? groupe.rendu(cle, membres) : <Mono ton="label">{cle}</Mono>}
1633
+ </div>
1634
+ )
1635
+ }
1636
+ : undefined
1374
1637
 
1375
- const total = table.getPrePaginationRowModel().rows.length
1638
+ const casesPied = pied && lignesRendues.length > 0 ? pied(affichees) : null
1639
+ const total = affichees.length
1376
1640
  const { pageIndex: page, pageSize } = table.getState().pagination
1377
1641
  const debut = total === 0 ? 0 : page * pageSize + 1
1378
1642
  const fin = Math.min(total, (page + 1) * pageSize)
1379
- const nbChoisies = Object.keys(rowSelection).length
1380
- const filtre = columnFilters.length > 0 || globalFilter !== ''
1643
+ const nbChoisies = externe ? externe.choisies.size : Object.keys(selectionInterne).length
1644
+
1645
+ const tableau = (
1646
+ <DndContext
1647
+ id={idDnd}
1648
+ sensors={capteurs}
1649
+ collisionDetection={closestCenter}
1650
+ modifiers={[axeHorizontal]}
1651
+ onDragEnd={finDeplacement}
1652
+ accessibility={{
1653
+ announcements: annonces,
1654
+ screenReaderInstructions: {
1655
+ draggable: t.dndInstructions,
1656
+ },
1657
+ }}
1658
+ >
1659
+ <SortableContext items={deplacables} strategy={horizontalListSortingStrategy}>
1660
+ <DataTable
1661
+ table={table}
1662
+ className="zv-table--grille"
1663
+ celluleEntete={(p) => <EnteteColonne {...p} t={t} />}
1664
+ sousEntete={rangeeFiltres}
1665
+ onRowClick={onRowClick}
1666
+ ligneActive={ligneActive}
1667
+ onRowContextMenu={onRowContextMenu}
1668
+ onRowDoubleClick={onRowDoubleClick}
1669
+ onRowDragStart={onRowDragStart}
1670
+ lignes={rang ? lignesRendues : undefined}
1671
+ intercalaire={intercalaire}
1672
+ avantLignes={
1673
+ ligneSaisie ? rangee('zv-row zv-row--saisie', (c) => ligneSaisie[c.id] ?? null) : undefined
1674
+ }
1675
+ apresLignes={
1676
+ <>
1677
+ {casesPied && rangee('zv-row zv-row--pied', (c) => casesPied[c.id] ?? null)}
1678
+ {apresLignes}
1679
+ </>
1680
+ }
1681
+ dense={dense}
1682
+ remplir={remplir}
1683
+ vide={
1684
+ vide && !filtre ? (
1685
+ vide
1686
+ ) : (
1687
+ <Empty>
1688
+ {data.length === 0 ? t.aucuneDonnee : filtre ? t.aucunResultat : t.aucuneLigne}{' '}
1689
+ {filtre && (
1690
+ <Button variante="fantome" compact onClick={effacerFiltres}>
1691
+ {t.effacerFiltres}
1692
+ </Button>
1693
+ )}
1694
+ </Empty>
1695
+ )
1696
+ }
1697
+ />
1698
+ </SortableContext>
1699
+ </DndContext>
1700
+ )
1381
1701
 
1382
1702
  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}
1703
+ <div className={cx('zv-grille', remplir && 'zv-grille--remplir', className)}>
1704
+ {barre && (
1705
+ <div className="zv-grille__outils">
1706
+ {champRecherche ??
1707
+ (recherche !== false ? (
1708
+ <Search
1709
+ value={globalFilter}
1710
+ onChange={(e) => setGlobalFilter(e.target.value)}
1711
+ placeholder={typeof recherche === 'string' ? recherche : t.rechercher}
1712
+ aria-label={t.rechercher}
1713
+ />
1714
+ ) : (
1715
+ <span />
1716
+ ))}
1717
+ <div className="zv-grille__actions">
1718
+ {avecCases && nbChoisies > 0 && <Meta>{t.choisies(nbChoisies)}</Meta>}
1719
+ {outils}
1720
+ {!onVisibilite && <MenuColonnes table={table} t={t} />}
1721
+ {exportCsv !== false && (
1722
+ <Button
1723
+ variante="fantome"
1724
+ compact
1725
+ icone="telecharger"
1726
+ onClick={() => telecharger(versCsv(table), typeof exportCsv === 'string' ? exportCsv : (tableId ?? 'export'))}
1727
+ >
1728
+ {t.exporter}
1729
+ </Button>
1730
+ )}
1731
+ <Button variante="fantome" compact icone="rembobiner" onClick={reinitialiser}>
1732
+ {t.reinitialiser}
1409
1733
  </Button>
1410
- )}
1411
- <Button variante="fantome" compact icone="rembobiner" onClick={reinitialiser}>
1412
- {t.reinitialiser}
1413
- </Button>
1734
+ </div>
1414
1735
  </div>
1415
- </div>
1736
+ )}
1416
1737
 
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>
1738
+ {envelopperTableau ? envelopperTableau(tableau) : tableau}
1448
1739
 
1449
1740
  {parPage !== false && (
1450
1741
  <Pager pied aria-label={t.pagination}>