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