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