@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/DECISIONS.md +49 -0
- package/dist/css-inline.d.ts.map +1 -1
- package/dist/css-inline.js +1 -1
- package/dist/css-inline.js.map +1 -1
- package/dist/data-table.d.ts +33 -3
- package/dist/data-table.d.ts.map +1 -1
- package/dist/data-table.js +58 -40
- package/dist/data-table.js.map +1 -1
- package/dist/grille.d.ts +93 -8
- package/dist/grille.d.ts.map +1 -1
- package/dist/grille.js +251 -112
- package/dist/grille.js.map +1 -1
- package/package.json +1 -1
- package/react/css-inline.ts +1 -1
- package/react/data-table.tsx +87 -6
- package/react/grille.tsx +627 -268
- package/src/data.css +101 -10
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,
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
|
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
|
|
498
|
+
return normaliser(brut).includes(normaliser(filtre.valeur.trim()))
|
|
405
499
|
case 'choix':
|
|
406
|
-
return String(brut ?? '')
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
*
|
|
880
|
-
*
|
|
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
|
|
883
|
-
* horizontalement, et `overflow-x: auto` force `overflow-y` : un
|
|
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
|
|
886
|
-
*
|
|
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
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
975
|
+
function FiltreFlottant({
|
|
976
|
+
libelle,
|
|
977
|
+
resume,
|
|
978
|
+
vide,
|
|
979
|
+
icone,
|
|
980
|
+
role,
|
|
981
|
+
panneau: contenu,
|
|
893
982
|
}: {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
979
|
-
//
|
|
980
|
-
//
|
|
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
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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 [
|
|
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:
|
|
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
|
|
1426
|
+
return avecCases ? [colonneSelection<T>(t, optionsCases), ...avecFiltre] : avecFiltre
|
|
1155
1427
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1156
|
-
}, [columns,
|
|
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:
|
|
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:
|
|
1449
|
+
onColumnVisibilityChange: onVisibilite
|
|
1450
|
+
? (u) => onVisibilite((v) => appliquer(u, v))
|
|
1451
|
+
: champ('columnVisibility'),
|
|
1176
1452
|
onColumnSizingChange: champ('columnSizing'),
|
|
1177
|
-
onSortingChange:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
|
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(
|
|
1307
|
-
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1735
|
+
)}
|
|
1343
1736
|
|
|
1344
|
-
|
|
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
|
-
<
|
|
1743
|
+
<SelectMenu
|
|
1380
1744
|
className="zv-grille__taille"
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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()}
|