@zevra/ui 0.43.0 → 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,
@@ -70,10 +71,12 @@ import { DataTable, colonnesAffichees, epinglage, type CelluleEnteteProps } from
70
71
  import { usePersistedState } from './use-persisted-state.js'
71
72
  import { Button } from './actions.js'
72
73
  import { Icon, IconButton } from './icon.js'
73
- import { Checkbox, Field, Input, Search, Select, SelectAnchor } from './forms.js'
74
+ import { Checkbox, Field, Input, Search, SelectAnchor } from './forms.js'
75
+ import { SelectMenu } from './select-menu.js'
74
76
  import { Menu, MenuItem, Pager, PagerArrow, Popover } from './navigation.js'
75
77
  import { Empty } from './feedback.js'
76
78
  import { Meta } from './data.js'
79
+ import { Mono } from './typography.js'
77
80
  import { cx } from './cx.js'
78
81
 
79
82
  export { usePersistedState }
@@ -93,6 +96,25 @@ export interface VueGrille {
93
96
  pageSize: number
94
97
  }
95
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
+
96
118
  /** Les tailles de page proposées au pied. */
97
119
  export const TAILLES_PAGE = [25, 50, 100] as const
98
120
 
@@ -113,6 +135,11 @@ export interface TextesGrille {
113
135
  min: string
114
136
  max: string
115
137
  effacer: string
138
+ effacerFiltres: string
139
+ toutAfficher: string
140
+ aucun: string
141
+ rechercherValeur: string
142
+ aucuneValeur: string
116
143
  /** Raccourcis de période du filtre de dates. */
117
144
  periodes: string
118
145
  aujourdhui: string
@@ -168,6 +195,11 @@ export const TEXTES_GRILLE: TextesGrille = {
168
195
  min: 'Min.',
169
196
  max: 'Max.',
170
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.',
171
203
  periodes: 'Périodes',
172
204
  aujourdhui: "Aujourd'hui",
173
205
  septJours: '7 derniers jours',
@@ -227,17 +259,71 @@ export interface DataGridProps<T> {
227
259
  /** Clic (ou Entrée) sur une ligne. Sans lui, la ligne n'est pas
228
260
  * cliquable, et elle n'en a pas l'air. */
229
261
  onRowClick?: (ligne: T) => void
262
+ /** La ligne dont l'aperçu est ouvert : filet d'accent à gauche. */
263
+ ligneActive?: (ligne: T) => boolean
230
264
  /** Identifiant de ligne. Indispensable avec `selection` dès que les
231
265
  * données se rechargent : par défaut c'est l'INDEX, et une ligne cochée
232
266
  * deviendrait une autre ligne après un tri côté serveur. */
233
267
  getRowId?: (ligne: T, index: number) => string
234
- /** Une colonne de cases, épinglée à gauche. */
235
- 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>
236
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
237
322
  /** Lignes par page (25, 50 ou 100), ou `false` pour tout afficher. */
238
323
  parPage?: (typeof TAILLES_PAGE)[number] | false
239
- /** Champ de recherche sur toutes les colonnes. Par défaut : oui. */
240
- 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
241
327
  /** Rangée de filtres sous les intitulés. Par défaut : oui. */
242
328
  filtres?: boolean
243
329
  /** Bouton d'export CSV des lignes filtrées et triées, colonnes visibles.
@@ -344,7 +430,7 @@ export type TypeFiltre = 'texte' | 'nombre' | 'date' | 'choix'
344
430
  /** La valeur d'un filtre de colonne, telle que TanStack la tient. */
345
431
  export type ValeurFiltre =
346
432
  | { type: 'texte'; valeur: string }
347
- | { type: 'choix'; valeur: string }
433
+ | { type: 'choix'; valeurs: string[] }
348
434
  | { type: 'nombre'; min: string; max: string }
349
435
  | { type: 'date'; du: string; au: string }
350
436
 
@@ -395,15 +481,23 @@ const nombre = (s: string): number | null => {
395
481
  return Number.isFinite(n) ? n : null
396
482
  }
397
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
+
398
492
  /** Le filtre de toutes les colonnes de la grille : il lit la FORME de la
399
493
  * valeur, pas la colonne. Bornes comprises. */
400
494
  export const filtreGrille: FilterFn<any> = (ligne, colonne, filtre: ValeurFiltre) => {
401
495
  const brut = ligne.getValue(colonne)
402
496
  switch (filtre?.type) {
403
497
  case 'texte':
404
- return String(brut ?? '').toLowerCase().includes(filtre.valeur.trim().toLowerCase())
498
+ return normaliser(brut).includes(normaliser(filtre.valeur.trim()))
405
499
  case 'choix':
406
- return String(brut ?? '') === filtre.valeur
500
+ return filtre.valeurs.includes(String(brut ?? ''))
407
501
  case 'nombre': {
408
502
  const v = typeof brut === 'number' ? brut : nombre(String(brut ?? ''))
409
503
  const min = nombre(filtre.min)
@@ -423,7 +517,7 @@ export const filtreGrille: FilterFn<any> = (ligne, colonne, filtre: ValeurFiltre
423
517
  filtreGrille.autoRemove = (f: ValeurFiltre | undefined) =>
424
518
  !f ||
425
519
  (f.type === 'texte' && f.valeur.trim() === '') ||
426
- (f.type === 'choix' && f.valeur === '') ||
520
+ (f.type === 'choix' && f.valeurs.length === 0) ||
427
521
  (f.type === 'nombre' && f.min === '' && f.max === '') ||
428
522
  (f.type === 'date' && f.du === '' && f.au === '')
429
523
 
@@ -682,28 +776,34 @@ function PoigneeLargeur<T>({
682
776
 
683
777
  /* ─── Cases de sélection ─────────────────────────────────────────────── */
684
778
 
685
- 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> }) {
686
782
  // Une enveloppe plutôt qu'une ref sur la case : `Checkbox` ne transmet
687
783
  // pas de ref sous React 18, que le paquet accepte encore.
688
784
  const ref = useRef<HTMLSpanElement>(null)
689
785
  const partiel = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
690
786
  // `indeterminate` n'a pas d'attribut HTML : seule la propriété DOM existe.
787
+ // Sans elle, « 3 sur 40 cochées » ressemble à « aucune ».
691
788
  useEffect(() => {
692
789
  const caseTout = ref.current?.querySelector('input')
693
790
  if (caseTout) caseTout.indeterminate = partiel
694
791
  }, [partiel])
792
+ const aucune = !table.getPrePaginationRowModel().rows.some((r) => r.getCanSelect())
695
793
  return (
696
794
  <span ref={ref} className="zv-cell--case">
697
- <Checkbox
698
- checked={table.getIsAllRowsSelected()}
699
- onChange={table.getToggleAllRowsSelectedHandler()}
700
- aria-label={t.toutSelectionner}
701
- />
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
+ />
702
802
  </span>
703
803
  )
704
804
  }
705
805
 
706
- function colonneSelection<T>(t: TextesGrille): ColumnDef<T, any> {
806
+ function colonneSelection<T>(t: TextesGrille, options: OptionsCases<T>): ColumnDef<T, any> {
707
807
  return {
708
808
  id: COLONNE_SELECTION,
709
809
  size: 40,
@@ -714,19 +814,22 @@ function colonneSelection<T>(t: TextesGrille): ColumnDef<T, any> {
714
814
  enableColumnFilter: false,
715
815
  enableGlobalFilter: false,
716
816
  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
- ),
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,
730
833
  meta: { classe: 'zv-cell--case' },
731
834
  }
732
835
  }
@@ -775,6 +878,15 @@ function MenuColonnes<T>({ table, t }: { table: TableTanstack<T>; t: TextesGrill
775
878
  </Button>
776
879
  {ouvert && (
777
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
+ )}
778
890
  {colonnes.map((c) => {
779
891
  const nom = libelleColonne(c)
780
892
  const cote = c.getIsPinned()
@@ -832,38 +944,10 @@ function FiltreColonne<T>({
832
944
  const nom = libelleColonne(colonne)
833
945
  const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
834
946
 
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
-
947
+ if (type === 'choix') return <FiltreChoix colonne={colonne} nom={nom} t={t} />
863
948
  if (type === 'nombre' || type === 'date') {
864
949
  return <FiltrePlage colonne={colonne} type={type} nom={nom} t={t} />
865
950
  }
866
-
867
951
  return (
868
952
  <Input
869
953
  className="zv-table__filtre"
@@ -876,55 +960,45 @@ function FiltreColonne<T>({
876
960
  }
877
961
 
878
962
  /**
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.
963
+ * Un filtre qui s'ouvre : le champ de la rangée RÉSUME la valeur, et ouvre
964
+ * un panneau (une liste de choix, une plage) sous lui.
881
965
  *
882
- * ⚠️ LE POPOVER SORT DU CADRE, par un portail. `.zv-table` défile
883
- * horizontalement, et `overflow-x: auto` force `overflow-y` : un popover
966
+ * ⚠️ LE PANNEAU SORT DU CADRE, par un portail. `.zv-table` défile
967
+ * horizontalement, et `overflow-x: auto` force `overflow-y` : un panneau
884
968
  * 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.
969
+ * tableau de trois lignes. Il est rendu dans le <body> et placé sous son
970
+ * déclencheur, en coordonnées d'écran.
971
+ *
972
+ * ⚠️ AUCUN <select> NATIF ICI : sa liste est peinte par le SYSTÈME, hors
973
+ * charte (menu noir sous macOS sombre), et certaines apps l'interdisent.
887
974
  */
888
- function FiltrePlage<T>({
889
- colonne,
890
- type,
891
- nom,
892
- t,
975
+ function FiltreFlottant({
976
+ libelle,
977
+ resume,
978
+ vide,
979
+ icone,
980
+ role,
981
+ panneau: contenu,
893
982
  }: {
894
- colonne: Column<T, unknown>
895
- type: 'nombre' | 'date'
896
- nom: string
897
- t: TextesGrille
983
+ libelle: string
984
+ resume: string
985
+ vide: boolean
986
+ icone: 'calendrier' | 'filtre' | 'chevron-bas'
987
+ role: 'dialog' | 'listbox'
988
+ /** Le contenu ; reçoit de quoi refermer le panneau. */
989
+ panneau: (fermer: () => void) => ReactNode
898
990
  }) {
899
991
  const [ouvert, setOuvert] = useState(false)
900
992
  const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
901
993
  const declencheur = useRef<HTMLButtonElement>(null)
902
994
  const panneau = useRef<HTMLDivElement>(null)
903
- const idChamp = useId()
904
995
 
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
- )
996
+ const fermer = useCallback(() => {
997
+ setOuvert(false)
998
+ declencheur.current?.focus()
999
+ }, [])
912
1000
 
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
1001
+ // Placé AVANT l'affichage (layout effect) : sinon le panneau apparaît un
928
1002
  // instant en haut à gauche de la page.
929
1003
  useLayoutEffect(() => {
930
1004
  if (!ouvert) {
@@ -939,8 +1013,8 @@ function FiltrePlage<T>({
939
1013
  const largeur = (panneau.current?.firstElementChild as HTMLElement | null)?.offsetWidth ?? 0
940
1014
  const vue = declencheur.current?.ownerDocument.defaultView
941
1015
  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.
1016
+ // Le décalage de 5 px sous le déclencheur est celui de .zv-popover et
1017
+ // de .zv-menu (top: calc(100% + 5px)) : l'enveloppe se pose au ras.
944
1018
  setPosition({ top: r.bottom, left: Math.max(0, Math.min(r.left, droite - largeur - 8)) })
945
1019
  }
946
1020
  placer()
@@ -962,10 +1036,7 @@ function FiltrePlage<T>({
962
1036
  if (!panneau.current?.contains(cible) && !declencheur.current?.contains(cible)) setOuvert(false)
963
1037
  }
964
1038
  const echap = (e: KeyboardEvent) => {
965
- if (e.key === 'Escape') {
966
- setOuvert(false)
967
- declencheur.current?.focus()
968
- }
1039
+ if (e.key === 'Escape') fermer()
969
1040
  }
970
1041
  doc.addEventListener('mousedown', dehors)
971
1042
  doc.addEventListener('keydown', echap)
@@ -973,16 +1044,171 @@ function FiltrePlage<T>({
973
1044
  doc.removeEventListener('mousedown', dehors)
974
1045
  doc.removeEventListener('keydown', echap)
975
1046
  }
976
- }, [ouvert])
1047
+ }, [ouvert, fermer])
977
1048
 
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.
1049
+ // Le focus entre dans le panneau une fois PLACÉ : avant, il est en
1050
+ // `visibility: hidden`, et le navigateur refuse le focus à un élément
1051
+ // invisible. La frappe partait alors dans le vide. L'option choisie
1052
+ // d'abord, sinon le premier champ ou la première option.
981
1053
  const place = position !== null
982
1054
  useEffect(() => {
983
- if (ouvert && place) panneau.current?.querySelector('input')?.focus()
1055
+ if (!ouvert || !place) return
1056
+ const p = panneau.current
1057
+ ;(p?.querySelector<HTMLElement>('[aria-selected="true"]') ?? p?.querySelector<HTMLElement>('input, button'))?.focus()
984
1058
  }, [ouvert, place])
985
1059
 
1060
+ return (
1061
+ <>
1062
+ <button
1063
+ ref={declencheur}
1064
+ type="button"
1065
+ className="zv-input zv-input--trigger zv-table__filtre"
1066
+ data-vide={vide ? 'oui' : undefined}
1067
+ aria-expanded={ouvert}
1068
+ aria-haspopup={role}
1069
+ aria-label={`${libelle} : ${resume}`}
1070
+ onClick={() => setOuvert((o) => !o)}
1071
+ >
1072
+ <span className="zv-colhead__label">{resume}</span>
1073
+ <Icon nom={icone} taille="sm" discret />
1074
+ </button>
1075
+ {ouvert &&
1076
+ createPortal(
1077
+ <div
1078
+ ref={panneau}
1079
+ className="zv-grille__flottant"
1080
+ style={position ? { top: position.top, left: position.left } : { visibility: 'hidden' }}
1081
+ >
1082
+ {contenu(fermer)}
1083
+ </div>,
1084
+ declencheur.current?.ownerDocument.body ?? document.body,
1085
+ )}
1086
+ </>
1087
+ )
1088
+ }
1089
+
1090
+ function FiltreChoix<T>({ colonne, nom, t }: { colonne: Column<T, unknown>; nom: string; t: TextesGrille }) {
1091
+ const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
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()]
1099
+ .filter((v) => v != null && v !== '')
1100
+ .map(String)
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}`
1105
+
1106
+ return (
1107
+ <FiltreFlottant
1108
+ libelle={t.filtrerColonne(nom)}
1109
+ resume={resume}
1110
+ vide={choisies.length === 0}
1111
+ icone="chevron-bas"
1112
+ role="dialog"
1113
+ panneau={() => <ListeChoix options={options} comptes={comptes} choisies={choisies} ecrire={ecrire} nom={nom} t={t} />}
1114
+ />
1115
+ )
1116
+ }
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
+
1178
+ function FiltrePlage<T>({
1179
+ colonne,
1180
+ type,
1181
+ nom,
1182
+ t,
1183
+ }: {
1184
+ colonne: Column<T, unknown>
1185
+ type: 'nombre' | 'date'
1186
+ nom: string
1187
+ t: TextesGrille
1188
+ }) {
1189
+ const idChamp = useId()
1190
+ const filtre = colonne.getFilterValue() as ValeurFiltre | undefined
1191
+ const [a, b] =
1192
+ filtre?.type === 'nombre' ? [filtre.min, filtre.max] : filtre?.type === 'date' ? [filtre.du, filtre.au] : ['', '']
1193
+ const ecrire = (bas: string, haut: string) =>
1194
+ colonne.setFilterValue(
1195
+ type === 'nombre' ? { type, min: bas, max: haut } : { type, du: bas, au: haut },
1196
+ )
1197
+
1198
+ // Ce qui est TAPÉ, distinct de ce qui est APPLIQUÉ : une date en cours de
1199
+ // saisie s'affiche dans le champ sans filtrer le tableau. Resynchronisé
1200
+ // quand le filtre change d'ailleurs (« Réinitialiser la vue »).
1201
+ const [brouillon, setBrouillon] = useState<[string, string]>([a, b])
1202
+ useEffect(() => {
1203
+ setBrouillon([a, b])
1204
+ }, [a, b])
1205
+ const saisir = (bas: string, haut: string) => {
1206
+ setBrouillon([bas, haut])
1207
+ if (type === 'nombre' || (dateComplete(bas) && dateComplete(haut))) ecrire(bas, haut)
1208
+ }
1209
+ const resume = type === 'nombre' ? t.resumeNombres(a, b) : t.resumeDates(a, b)
1210
+ const vide = !a && !b
1211
+
986
1212
  const champ = (
987
1213
  valeur: string,
988
1214
  changer: (v: string) => void,
@@ -1014,61 +1240,51 @@ function FiltrePlage<T>({
1014
1240
  )
1015
1241
 
1016
1242
  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
- </>
1243
+ <FiltreFlottant
1244
+ libelle={t.filtrerColonne(nom)}
1245
+ resume={resume}
1246
+ vide={vide}
1247
+ icone={type === 'date' ? 'calendrier' : 'filtre'}
1248
+ role="dialog"
1249
+ panneau={() => (
1250
+ <Popover className="zv-grille__plage" role="dialog" aria-label={t.filtrerColonne(nom)}>
1251
+ {type === 'date' && (
1252
+ <div className="zv-grille__periodes" role="group" aria-label={t.periodes}>
1253
+ {(['aujourdhui', 'septJours', 'trenteJours', 'ceMois', 'cetteAnnee'] as const).map((cle) => {
1254
+ const [du, au] = periodes(new Date())[cle]
1255
+ return (
1256
+ // `role="button"` : ces raccourcis sont des ACTIONS, pas
1257
+ // les options d'une liste (le rôle par défaut de MenuItem).
1258
+ <MenuItem
1259
+ key={cle}
1260
+ role="button"
1261
+ actif={a === du && b === au}
1262
+ aria-selected={undefined}
1263
+ aria-pressed={a === du && b === au}
1264
+ onClick={() => saisir(du, au)}
1265
+ >
1266
+ {t[cle]}
1267
+ </MenuItem>
1268
+ )
1269
+ })}
1270
+ </div>
1271
+ )}
1272
+ <div className="zv-grille__bornes">
1273
+ <Field label={type === 'date' ? t.du : t.min} htmlFor={idChamp + 'a'}>
1274
+ {champ(brouillon[0], (v) => saisir(v, brouillon[1]), 'a', { max: brouillon[1] })}
1275
+ </Field>
1276
+ <Field label={type === 'date' ? t.au : t.max} htmlFor={idChamp + 'b'}>
1277
+ {champ(brouillon[1], (v) => saisir(brouillon[0], v), 'b', { min: brouillon[0] })}
1278
+ </Field>
1279
+ </div>
1280
+ {!vide && (
1281
+ <Button variante="fantome" compact icone="fermer" onClick={() => saisir('', '')}>
1282
+ {t.effacer}
1283
+ </Button>
1284
+ )}
1285
+ </Popover>
1286
+ )}
1287
+ />
1072
1288
  )
1073
1289
  }
1074
1290
 
@@ -1079,9 +1295,27 @@ export function DataGrid<T>({
1079
1295
  data,
1080
1296
  columns,
1081
1297
  onRowClick,
1298
+ ligneActive,
1082
1299
  getRowId,
1083
1300
  selection = false,
1084
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,
1085
1319
  parPage = 25,
1086
1320
  recherche = true,
1087
1321
  filtres = true,
@@ -1130,7 +1364,7 @@ export function DataGrid<T>({
1130
1364
 
1131
1365
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
1132
1366
  const [globalFilter, setGlobalFilter] = useState('')
1133
- const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
1367
+ const [selectionInterne, setSelectionInterne] = useState<RowSelectionState>({})
1134
1368
  const [pageIndex, setPageIndex] = useState(0)
1135
1369
 
1136
1370
  const champ =
@@ -1138,10 +1372,44 @@ export function DataGrid<T>({
1138
1372
  (u) =>
1139
1373
  setVue((v) => ({ ...v, [k]: appliquer(u, v[k]) }))
1140
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
+
1141
1409
  // La colonne de cases est épinglée à gauche, toujours, et n'entre pas dans
1142
1410
  // la vue enregistrée : elle dépend de la prop, pas de l'utilisateur.
1143
1411
  const pinning: ColumnPinningState = {
1144
- left: selection ? [COLONNE_SELECTION, ...(vue.columnPinning.left ?? [])] : vue.columnPinning.left,
1412
+ left: avecCases ? [COLONNE_SELECTION, ...(vue.columnPinning.left ?? [])] : vue.columnPinning.left,
1145
1413
  right: vue.columnPinning.right,
1146
1414
  }
1147
1415
  const pagination: PaginationState = {
@@ -1149,11 +1417,17 @@ export function DataGrid<T>({
1149
1417
  pageSize: parPage === false ? Math.max(data.length, 1) : vue.pageSize,
1150
1418
  }
1151
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
1152
1424
  const colonnes = useMemo<ColumnDef<T, any>[]>(() => {
1153
1425
  const avecFiltre = columns.map((c) => (c.filterFn ? c : { ...c, filterFn: filtreGrille }))
1154
- return selection ? [colonneSelection<T>(t), ...avecFiltre] : avecFiltre
1426
+ return avecCases ? [colonneSelection<T>(t, optionsCases), ...avecFiltre] : avecFiltre
1155
1427
  // eslint-disable-next-line react-hooks/exhaustive-deps
1156
- }, [columns, selection, t.toutSelectionner, t.selectionnerLigne])
1428
+ }, [columns, avecCases, t.toutSelectionner, t.selectionnerLigne])
1429
+
1430
+ const visibiliteCourante = visibilite ?? vue.columnVisibility
1157
1431
 
1158
1432
  const table = useReactTable<T>({
1159
1433
  data,
@@ -1161,10 +1435,10 @@ export function DataGrid<T>({
1161
1435
  getRowId,
1162
1436
  state: {
1163
1437
  columnOrder: vue.columnOrder,
1164
- columnVisibility: vue.columnVisibility,
1438
+ columnVisibility: visibiliteCourante,
1165
1439
  columnSizing: vue.columnSizing,
1166
1440
  columnPinning: pinning,
1167
- sorting: vue.sorting,
1441
+ sorting: triServeur ? triServeur.tri : vue.sorting,
1168
1442
  columnFilters,
1169
1443
  globalFilter,
1170
1444
  rowSelection,
@@ -1172,13 +1446,18 @@ export function DataGrid<T>({
1172
1446
  },
1173
1447
  defaultColumn: { size: 150, minSize: 60 },
1174
1448
  onColumnOrderChange: champ('columnOrder'),
1175
- onColumnVisibilityChange: champ('columnVisibility'),
1449
+ onColumnVisibilityChange: onVisibilite
1450
+ ? (u) => onVisibilite((v) => appliquer(u, v))
1451
+ : champ('columnVisibility'),
1176
1452
  onColumnSizingChange: champ('columnSizing'),
1177
- onSortingChange: champ('sorting'),
1453
+ onSortingChange: triServeur
1454
+ ? (u) => triServeur.onTri(appliquer(u, triServeur.tri))
1455
+ : champ('sorting'),
1456
+ manualSorting: Boolean(triServeur),
1178
1457
  onColumnPinningChange: (u) =>
1179
1458
  setVue((v) => {
1180
1459
  const actuel = {
1181
- left: selection ? [COLONNE_SELECTION, ...(v.columnPinning.left ?? [])] : v.columnPinning.left,
1460
+ left: avecCases ? [COLONNE_SELECTION, ...(v.columnPinning.left ?? [])] : v.columnPinning.left,
1182
1461
  right: v.columnPinning.right,
1183
1462
  }
1184
1463
  const n = appliquer(u, actuel)
@@ -1192,7 +1471,7 @@ export function DataGrid<T>({
1192
1471
  }),
1193
1472
  onColumnFiltersChange: setColumnFilters,
1194
1473
  onGlobalFilterChange: setGlobalFilter,
1195
- onRowSelectionChange: setRowSelection,
1474
+ onRowSelectionChange: surSelectionLignes,
1196
1475
  onPaginationChange: (u) => {
1197
1476
  const n = appliquer(u, pagination)
1198
1477
  setPageIndex(n.pageIndex)
@@ -1200,14 +1479,17 @@ export function DataGrid<T>({
1200
1479
  setVue((v) => ({ ...v, pageSize: n.pageSize }))
1201
1480
  }
1202
1481
  },
1203
- enableRowSelection: selection,
1482
+ enableRowSelection: externe?.selectionnable
1483
+ ? (r: Row<T>) => externe.selectionnable!(r.original)
1484
+ : avecCases,
1204
1485
  enableMultiSort: true,
1205
1486
  enableColumnResizing: true,
1206
1487
  columnResizeMode: 'onChange',
1207
1488
  enableColumnPinning: true,
1208
- 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())),
1209
1491
  getCoreRowModel: getCoreRowModel(),
1210
- getSortedRowModel: getSortedRowModel(),
1492
+ getSortedRowModel: triServeur ? undefined : getSortedRowModel(),
1211
1493
  getFilteredRowModel: getFilteredRowModel(),
1212
1494
  getPaginationRowModel: getPaginationRowModel(),
1213
1495
  getFacetedRowModel: getFacetedRowModel(),
@@ -1219,9 +1501,10 @@ export function DataGrid<T>({
1219
1501
  const surSelection = useRef(onSelectionChange)
1220
1502
  surSelection.current = onSelectionChange
1221
1503
  useEffect(() => {
1504
+ if (externe) return
1222
1505
  surSelection.current?.(table.getSelectedRowModel().rows.map((r) => r.original))
1223
1506
  // eslint-disable-next-line react-hooks/exhaustive-deps
1224
- }, [rowSelection])
1507
+ }, [selectionInterne])
1225
1508
 
1226
1509
  // Une page au-delà de la dernière (filtre resserré, données rechargées)
1227
1510
  // afficherait la ligne d'absence alors que des lignes existent.
@@ -1230,18 +1513,22 @@ export function DataGrid<T>({
1230
1513
  if (pageIndex > 0 && pageIndex >= nbPages) setPageIndex(Math.max(0, nbPages - 1))
1231
1514
  }, [pageIndex, nbPages])
1232
1515
 
1233
- const reinitialiser = () => {
1234
- reinitialiserVue()
1516
+ const effacerFiltres = () => {
1235
1517
  setColumnFilters([])
1236
1518
  setGlobalFilter('')
1519
+ }
1520
+ const reinitialiser = () => {
1521
+ reinitialiserVue()
1522
+ if (onVisibilite) onVisibilite(() => defaut.columnVisibility)
1523
+ effacerFiltres()
1237
1524
  setPageIndex(0)
1238
1525
  }
1239
1526
 
1240
1527
  /* Glisser-déposer des en-têtes. */
1241
1528
  const idDnd = useId()
1242
1529
  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.
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.
1245
1532
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
1246
1533
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
1247
1534
  )
@@ -1284,110 +1571,182 @@ export function DataGrid<T>({
1284
1571
  // eslint-disable-next-line react-hooks/exhaustive-deps
1285
1572
  }, [lignesBase, colonnes])
1286
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
+ )
1287
1594
  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)
1595
+ filtres && filtrables
1596
+ ? rangee('zv-table__head zv-table__head--filtres', (c) => {
1292
1597
  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
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() !== ''
1301
1615
 
1302
- const total = table.getPrePaginationRowModel().rows.length
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
1636
+
1637
+ const casesPied = pied && lignesRendues.length > 0 ? pied(affichees) : null
1638
+ const total = affichees.length
1303
1639
  const { pageIndex: page, pageSize } = table.getState().pagination
1304
1640
  const debut = total === 0 ? 0 : page * pageSize + 1
1305
1641
  const fin = Math.min(total, (page + 1) * pageSize)
1306
- const nbChoisies = Object.keys(rowSelection).length
1307
- 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
+ )
1308
1700
 
1309
1701
  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}
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}
1336
1732
  </Button>
1337
- )}
1338
- <Button variante="fantome" compact icone="rembobiner" onClick={reinitialiser}>
1339
- {t.reinitialiser}
1340
- </Button>
1733
+ </div>
1341
1734
  </div>
1342
- </div>
1735
+ )}
1343
1736
 
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>
1737
+ {envelopperTableau ? envelopperTableau(tableau) : tableau}
1374
1738
 
1375
1739
  {parPage !== false && (
1376
1740
  <Pager pied aria-label={t.pagination}>
1377
1741
  <Meta>{t.compte(debut, fin, total)}</Meta>
1378
1742
  <div className="zv-grille__pied">
1379
- <Select
1743
+ <SelectMenu
1380
1744
  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>
1745
+ label={t.lignesParPage}
1746
+ valeur={String(pageSize)}
1747
+ options={TAILLES_PAGE.map((taille) => ({ valeur: String(taille), label: t.parPage(taille) }))}
1748
+ onChange={(v) => table.setPageSize(Number(v))}
1749
+ />
1391
1750
  <PagerArrow
1392
1751
  aria-label={t.pagePrecedente}
1393
1752
  desactive={!table.getCanPreviousPage()}