@zevra/ui 0.41.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/catalogue.js +1 -1
- 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/icon-data.d.ts +1 -1
- package/dist/icon-data.d.ts.map +1 -1
- package/dist/icon-data.js +2 -0
- package/dist/icon-data.js.map +1 -1
- package/dist/icon.d.ts +1 -1
- package/dist/icon.js +1 -1
- 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/icons/index.json +6 -0
- package/icons/sprite.svg +1 -0
- package/package.json +16 -2
- package/react/catalogue.tsx +1 -1
- package/react/css-inline.ts +1 -1
- package/react/data-table.tsx +138 -26
- package/react/grille.tsx +1411 -0
- package/react/icon-data.ts +3 -0
- package/react/icon.tsx +2 -2
- package/react/use-persisted-state.ts +105 -0
- package/src/data.css +381 -0
- package/src/forms.css +14 -0
package/dist/grille.js
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
17
|
+
import { createPortal } from 'react-dom';
|
|
18
|
+
import { getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table';
|
|
19
|
+
import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useDndMonitor, useSensor, useSensors, } from '@dnd-kit/core';
|
|
20
|
+
import { SortableContext, arrayMove, horizontalListSortingStrategy, sortableKeyboardCoordinates, useSortable, } from '@dnd-kit/sortable';
|
|
21
|
+
import { DataTable, colonnesAffichees, epinglage } from './data-table.js';
|
|
22
|
+
import { usePersistedState } from './use-persisted-state.js';
|
|
23
|
+
import { Button } from './actions.js';
|
|
24
|
+
import { Icon, IconButton } from './icon.js';
|
|
25
|
+
import { Checkbox, Field, Input, Search, Select, SelectAnchor } from './forms.js';
|
|
26
|
+
import { Menu, MenuItem, Pager, PagerArrow, Popover } from './navigation.js';
|
|
27
|
+
import { Empty } from './feedback.js';
|
|
28
|
+
import { Meta } from './data.js';
|
|
29
|
+
import { cx } from './cx.js';
|
|
30
|
+
export { usePersistedState };
|
|
31
|
+
/** Identifiant réservé de la colonne de cases. */
|
|
32
|
+
export const COLONNE_SELECTION = '__selection';
|
|
33
|
+
/** Les tailles de page proposées au pied. */
|
|
34
|
+
export const TAILLES_PAGE = [25, 50, 100];
|
|
35
|
+
export const TEXTES_GRILLE = {
|
|
36
|
+
rechercher: 'Rechercher dans le tableau',
|
|
37
|
+
colonnes: 'Colonnes',
|
|
38
|
+
colonnesAffichees: 'Colonnes affichées',
|
|
39
|
+
exporter: 'Exporter',
|
|
40
|
+
reinitialiser: 'Réinitialiser la vue',
|
|
41
|
+
filtrer: 'Filtrer',
|
|
42
|
+
tous: 'Tous',
|
|
43
|
+
du: 'Du',
|
|
44
|
+
au: 'Au',
|
|
45
|
+
min: 'Min.',
|
|
46
|
+
max: 'Max.',
|
|
47
|
+
effacer: 'Effacer le filtre',
|
|
48
|
+
periodes: 'Périodes',
|
|
49
|
+
aujourdhui: "Aujourd'hui",
|
|
50
|
+
septJours: '7 derniers jours',
|
|
51
|
+
trenteJours: '30 derniers jours',
|
|
52
|
+
ceMois: 'Ce mois-ci',
|
|
53
|
+
cetteAnnee: 'Cette année',
|
|
54
|
+
resumeNombres: (min, max) => min && max ? `${min} – ${max}` : min ? `≥ ${min}` : max ? `≤ ${max}` : 'Tous',
|
|
55
|
+
resumeDates: (du, au) => {
|
|
56
|
+
const fr = (d) => d.split('-').reverse().join('/');
|
|
57
|
+
return du && au ? `${fr(du)} → ${fr(au)}` : du ? `dès le ${fr(du)}` : au ? `jusqu'au ${fr(au)}` : 'Toutes';
|
|
58
|
+
},
|
|
59
|
+
filtrerColonne: (nom) => `Filtrer la colonne ${nom}`,
|
|
60
|
+
deplacer: (nom) => `Déplacer la colonne ${nom}`,
|
|
61
|
+
largeur: (nom) => `Largeur de la colonne ${nom}`,
|
|
62
|
+
largeurAide: "Glisser pour élargir. Double-clic : largeur d'origine.",
|
|
63
|
+
epinglerGauche: (nom) => `Épingler ${nom} à gauche`,
|
|
64
|
+
epinglerDroite: (nom) => `Épingler ${nom} à droite`,
|
|
65
|
+
detacher: (nom) => `Détacher ${nom}`,
|
|
66
|
+
aGauche: 'à gauche',
|
|
67
|
+
aDroite: 'à droite',
|
|
68
|
+
toutSelectionner: 'Sélectionner toutes les lignes',
|
|
69
|
+
selectionnerLigne: 'Sélectionner la ligne',
|
|
70
|
+
choisies: (n) => (n > 1 ? `${n} lignes choisies` : `${n} ligne choisie`),
|
|
71
|
+
aucuneDonnee: 'Aucune ligne pour le moment.',
|
|
72
|
+
aucunResultat: 'Aucune ligne ne correspond à ces filtres.',
|
|
73
|
+
aucuneLigne: 'Aucune ligne.',
|
|
74
|
+
compte: (debut, fin, total) => (total === 0 ? '0 ligne' : `${debut} – ${fin} sur ${total}`),
|
|
75
|
+
parPage: (n) => `${n} par page`,
|
|
76
|
+
lignesParPage: 'Lignes par page',
|
|
77
|
+
pagination: 'Pagination du tableau',
|
|
78
|
+
pagePrecedente: 'Page précédente',
|
|
79
|
+
pageSuivante: 'Page suivante',
|
|
80
|
+
page: (page, pages) => `p. ${page} / ${pages}`,
|
|
81
|
+
dndInstructions: 'Pour déplacer la colonne, appuyez sur Espace, puis sur les flèches gauche et droite. Espace pour la poser, Échap pour annuler.',
|
|
82
|
+
dndSaisie: (nom) => `Colonne ${nom} saisie.`,
|
|
83
|
+
dndSurvol: (nom, cible) => `Colonne ${nom} à la place de ${cible}.`,
|
|
84
|
+
dndHors: (nom) => `Colonne ${nom} hors des colonnes.`,
|
|
85
|
+
dndPose: (nom, cible) => `Colonne ${nom} posée à la place de ${cible}.`,
|
|
86
|
+
dndReposee: (nom) => `Colonne ${nom} reposée.`,
|
|
87
|
+
dndAnnule: (nom) => `Déplacement annulé : la colonne ${nom} reprend sa place.`,
|
|
88
|
+
};
|
|
89
|
+
/* ─── Fonctions pures (testées) ──────────────────────────────────────── */
|
|
90
|
+
/** L'identifiant d'une définition de colonne, tel que TanStack le dérive. */
|
|
91
|
+
export function idDeColonne(c) {
|
|
92
|
+
if (c.id)
|
|
93
|
+
return c.id;
|
|
94
|
+
const cle = c.accessorKey;
|
|
95
|
+
return typeof cle === 'string' ? cle.replace(/\./g, '_') : undefined;
|
|
96
|
+
}
|
|
97
|
+
const estObjet = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
98
|
+
const listeIds = (v, connus) => Array.isArray(v)
|
|
99
|
+
? [...new Set(v.filter((x) => typeof x === 'string' && connus.has(x)))]
|
|
100
|
+
: [];
|
|
101
|
+
/**
|
|
102
|
+
* Adapte une vue LUE (d'une version précédente du code, ou abîmée) aux
|
|
103
|
+
* colonnes d'aujourd'hui : un identifiant qui n'existe plus est ignoré, une
|
|
104
|
+
* colonne nouvelle s'ajoute en FIN d'ordre. Sans cela, ajouter une colonne
|
|
105
|
+
* au code la ferait disparaître chez tous ceux qui avaient déplacé une
|
|
106
|
+
* colonne avant. Rend `undefined` si rien n'est exploitable.
|
|
107
|
+
*/
|
|
108
|
+
export function reconcilierVue(lu, defaut, ids) {
|
|
109
|
+
if (!estObjet(lu))
|
|
110
|
+
return undefined;
|
|
111
|
+
const connus = new Set(ids);
|
|
112
|
+
const ordreLu = listeIds(lu.columnOrder, connus);
|
|
113
|
+
const columnOrder = ordreLu.length
|
|
114
|
+
? [...ordreLu, ...ids.filter((id) => !ordreLu.includes(id))]
|
|
115
|
+
: defaut.columnOrder;
|
|
116
|
+
const filtrer = (objet, garder) => {
|
|
117
|
+
const sortie = {};
|
|
118
|
+
if (estObjet(objet)) {
|
|
119
|
+
for (const [k, v] of Object.entries(objet))
|
|
120
|
+
if (connus.has(k) && garder(v))
|
|
121
|
+
sortie[k] = v;
|
|
122
|
+
}
|
|
123
|
+
return sortie;
|
|
124
|
+
};
|
|
125
|
+
const columnVisibility = estObjet(lu.columnVisibility)
|
|
126
|
+
? filtrer(lu.columnVisibility, (v) => typeof v === 'boolean')
|
|
127
|
+
: defaut.columnVisibility;
|
|
128
|
+
const columnSizing = filtrer(lu.columnSizing, (v) => typeof v === 'number' && Number.isFinite(v) && v > 0);
|
|
129
|
+
let columnPinning = defaut.columnPinning;
|
|
130
|
+
if (estObjet(lu.columnPinning)) {
|
|
131
|
+
const left = listeIds(lu.columnPinning.left, connus);
|
|
132
|
+
const right = listeIds(lu.columnPinning.right, connus).filter((id) => !left.includes(id));
|
|
133
|
+
columnPinning = { left, right };
|
|
134
|
+
}
|
|
135
|
+
const sorting = Array.isArray(lu.sorting)
|
|
136
|
+
? lu.sorting
|
|
137
|
+
.filter((s) => estObjet(s) && typeof s.id === 'string' && connus.has(s.id) && typeof s.desc === 'boolean')
|
|
138
|
+
.map((s) => ({ id: s.id, desc: s.desc }))
|
|
139
|
+
: defaut.sorting;
|
|
140
|
+
const pageSize = TAILLES_PAGE.includes(lu.pageSize)
|
|
141
|
+
? lu.pageSize
|
|
142
|
+
: defaut.pageSize;
|
|
143
|
+
return { columnOrder, columnVisibility, columnSizing, columnPinning, sorting, pageSize };
|
|
144
|
+
}
|
|
145
|
+
/** Une valeur ramenée au jour, `AAAA-MM-JJ`, dans le fuseau du NAVIGATEUR :
|
|
146
|
+
* une échéance au 3 mars à 23 h 30 à Paris est le 3 mars, pas le 4 qu'en
|
|
147
|
+
* dirait `toISOString()`. Rend `null` pour ce qui n'est pas une date. */
|
|
148
|
+
export function versJour(v) {
|
|
149
|
+
if (v instanceof Date) {
|
|
150
|
+
if (Number.isNaN(v.getTime()))
|
|
151
|
+
return null;
|
|
152
|
+
const deux = (n) => String(n).padStart(2, '0');
|
|
153
|
+
return `${v.getFullYear()}-${deux(v.getMonth() + 1)}-${deux(v.getDate())}`;
|
|
154
|
+
}
|
|
155
|
+
if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v))
|
|
156
|
+
return v.slice(0, 10);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
/** Au-delà, une liste déroulante devient plus longue à parcourir qu'un mot
|
|
160
|
+
* à taper : la colonne reste en texte libre. */
|
|
161
|
+
export const CHOIX_MAX = 20;
|
|
162
|
+
/**
|
|
163
|
+
* La famille de filtre d'une colonne, d'après ses valeurs : `meta.filtre`
|
|
164
|
+
* d'abord, puis un nombre (registre `num`, ou valeurs numériques), une date
|
|
165
|
+
* (objets `Date` ou chaînes ISO), un CHOIX quand les valeurs distinctes sont
|
|
166
|
+
* peu nombreuses ET se répètent (sinon chaque ligne serait une option), du
|
|
167
|
+
* texte enfin.
|
|
168
|
+
*/
|
|
169
|
+
export function typeDeFiltre(meta, valeurs) {
|
|
170
|
+
if (meta?.filtre !== undefined)
|
|
171
|
+
return meta.filtre;
|
|
172
|
+
if (meta?.registre === 'num')
|
|
173
|
+
return 'nombre';
|
|
174
|
+
const presentes = valeurs.filter((v) => v != null && v !== '');
|
|
175
|
+
if (presentes.length === 0)
|
|
176
|
+
return 'texte';
|
|
177
|
+
if (presentes.every((v) => typeof v === 'number'))
|
|
178
|
+
return 'nombre';
|
|
179
|
+
if (presentes.every((v) => versJour(v) !== null))
|
|
180
|
+
return 'date';
|
|
181
|
+
if (meta?.registre !== 'nom' && presentes.every((v) => typeof v === 'string' || typeof v === 'boolean')) {
|
|
182
|
+
const distinctes = new Set(presentes.map(String)).size;
|
|
183
|
+
if (distinctes <= CHOIX_MAX && distinctes < presentes.length)
|
|
184
|
+
return 'choix';
|
|
185
|
+
}
|
|
186
|
+
return 'texte';
|
|
187
|
+
}
|
|
188
|
+
const nombre = (s) => {
|
|
189
|
+
if (s.trim() === '')
|
|
190
|
+
return null;
|
|
191
|
+
const n = Number(s.replace(',', '.'));
|
|
192
|
+
return Number.isFinite(n) ? n : null;
|
|
193
|
+
};
|
|
194
|
+
/** Le filtre de toutes les colonnes de la grille : il lit la FORME de la
|
|
195
|
+
* valeur, pas la colonne. Bornes comprises. */
|
|
196
|
+
export const filtreGrille = (ligne, colonne, filtre) => {
|
|
197
|
+
const brut = ligne.getValue(colonne);
|
|
198
|
+
switch (filtre?.type) {
|
|
199
|
+
case 'texte':
|
|
200
|
+
return String(brut ?? '').toLowerCase().includes(filtre.valeur.trim().toLowerCase());
|
|
201
|
+
case 'choix':
|
|
202
|
+
return String(brut ?? '') === filtre.valeur;
|
|
203
|
+
case 'nombre': {
|
|
204
|
+
const v = typeof brut === 'number' ? brut : nombre(String(brut ?? ''));
|
|
205
|
+
const min = nombre(filtre.min);
|
|
206
|
+
const max = nombre(filtre.max);
|
|
207
|
+
if (v == null)
|
|
208
|
+
return min == null && max == null;
|
|
209
|
+
return (min == null || v >= min) && (max == null || v <= max);
|
|
210
|
+
}
|
|
211
|
+
case 'date': {
|
|
212
|
+
const jourLigne = versJour(brut);
|
|
213
|
+
if (jourLigne == null)
|
|
214
|
+
return !filtre.du && !filtre.au;
|
|
215
|
+
return (!filtre.du || jourLigne >= filtre.du) && (!filtre.au || jourLigne <= filtre.au);
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
filtreGrille.autoRemove = (f) => !f ||
|
|
222
|
+
(f.type === 'texte' && f.valeur.trim() === '') ||
|
|
223
|
+
(f.type === 'choix' && f.valeur === '') ||
|
|
224
|
+
(f.type === 'nombre' && f.min === '' && f.max === '') ||
|
|
225
|
+
(f.type === 'date' && f.du === '' && f.au === '');
|
|
226
|
+
/** Ne garde d'une saisie que ce qui peut faire un nombre : chiffres, un
|
|
227
|
+
* signe moins en tête, un séparateur décimal. Les lettres ne s'écrivent
|
|
228
|
+
* pas, plutôt que de s'écrire puis d'être refusées. */
|
|
229
|
+
export function saisieNombre(texte) {
|
|
230
|
+
const signe = texte.trimStart().startsWith('-') ? '-' : '';
|
|
231
|
+
const [entier, ...reste] = texte.replace(/[^\d.,]/g, '').split(/[.,]/);
|
|
232
|
+
return signe + entier + (reste.length ? ',' + reste.join('') : '');
|
|
233
|
+
}
|
|
234
|
+
/** Une date saisie est-elle COMPLÈTE ? Le champ date natif rend une valeur
|
|
235
|
+
* dès que les trois segments sont remplis, y compris pendant qu'on tape
|
|
236
|
+
* l'année : « 0002 », puis « 0020 », puis « 0202 ». Filtrer sur ces états
|
|
237
|
+
* intermédiaires faisait disparaître puis revenir toutes les lignes à
|
|
238
|
+
* chaque chiffre. On n'applique qu'une année plausible. */
|
|
239
|
+
export function dateComplete(v) {
|
|
240
|
+
return v === '' || (/^\d{4}-\d{2}-\d{2}$/.test(v) && Number(v.slice(0, 4)) >= 1900);
|
|
241
|
+
}
|
|
242
|
+
/** Les raccourcis de période, en jours ISO, calculés depuis `maintenant`. */
|
|
243
|
+
export function periodes(maintenant) {
|
|
244
|
+
const jour = (d) => versJour(d);
|
|
245
|
+
const decale = (n) => {
|
|
246
|
+
const d = new Date(maintenant);
|
|
247
|
+
d.setDate(d.getDate() - n);
|
|
248
|
+
return jour(d);
|
|
249
|
+
};
|
|
250
|
+
const aujourdhui = jour(maintenant);
|
|
251
|
+
return {
|
|
252
|
+
aujourdhui: [aujourdhui, aujourdhui],
|
|
253
|
+
septJours: [decale(6), aujourdhui],
|
|
254
|
+
trenteJours: [decale(29), aujourdhui],
|
|
255
|
+
ceMois: [jour(new Date(maintenant.getFullYear(), maintenant.getMonth(), 1)), aujourdhui],
|
|
256
|
+
cetteAnnee: [jour(new Date(maintenant.getFullYear(), 0, 1)), aujourdhui],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** L'intitulé lisible d'une colonne : pour les menus, l'export, les annonces. */
|
|
260
|
+
export function libelleColonne(c) {
|
|
261
|
+
const h = c.columnDef.header;
|
|
262
|
+
return typeof h === 'string' ? h : (c.columnDef.meta?.label ?? c.id);
|
|
263
|
+
}
|
|
264
|
+
/** Les lignes filtrées et triées, colonnes visibles, en CSV : point-virgule
|
|
265
|
+
* (le séparateur qu'Excel attend en français), guillemets doublés, dates en
|
|
266
|
+
* ISO. Les VALEURS, pas le rendu : une pastille d'état s'exporte par son
|
|
267
|
+
* libellé de données, pas par son HTML. */
|
|
268
|
+
export function versCsv(table) {
|
|
269
|
+
const colonnes = colonnesAffichees(table).filter((c) => c.id !== COLONNE_SELECTION);
|
|
270
|
+
const echapper = (v) => {
|
|
271
|
+
const s = v == null ? '' : v instanceof Date ? v.toISOString().slice(0, 10) : String(v);
|
|
272
|
+
return /[";\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
273
|
+
};
|
|
274
|
+
const lignes = table.getPrePaginationRowModel().rows;
|
|
275
|
+
return [
|
|
276
|
+
colonnes.map((c) => echapper(libelleColonne(c))).join(';'),
|
|
277
|
+
...lignes.map((l) => colonnes.map((c) => echapper(l.getValue(c.id))).join(';')),
|
|
278
|
+
].join('\r\n');
|
|
279
|
+
}
|
|
280
|
+
function telecharger(texte, nom) {
|
|
281
|
+
// BOM UTF-8 : sans lui, Excel ouvre le fichier en Windows-1252 et
|
|
282
|
+
// « Déposé » devient « Déposé ».
|
|
283
|
+
const url = URL.createObjectURL(new Blob(['' + texte], { type: 'text/csv;charset=utf-8' }));
|
|
284
|
+
const lien = document.createElement('a');
|
|
285
|
+
lien.href = url;
|
|
286
|
+
lien.download = `${nom}.csv`;
|
|
287
|
+
lien.click();
|
|
288
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
289
|
+
}
|
|
290
|
+
const appliquer = (u, ancien) => typeof u === 'function' ? u(ancien) : u;
|
|
291
|
+
/* ─── En-tête : poignée de déplacement et poignée de largeur ─────────── */
|
|
292
|
+
// Le glisser-déposer ne bouge que sur l'axe des colonnes : sans ce
|
|
293
|
+
// modificateur, l'en-tête saisi suit le pointeur en hauteur et sort du cadre.
|
|
294
|
+
const axeHorizontal = ({ transform }) => ({ ...transform, y: 0 });
|
|
295
|
+
function EnteteColonne(p) {
|
|
296
|
+
const colonne = p.entete.column;
|
|
297
|
+
const table = p.entete.getContext().table;
|
|
298
|
+
const nom = libelleColonne(colonne);
|
|
299
|
+
const t = p.t;
|
|
300
|
+
// Ni la colonne de cases ni une colonne épinglée ne se déplacent : elles
|
|
301
|
+
// ont une place fixe, et TanStack les rend hors de l'ordre de toute façon.
|
|
302
|
+
const deplacable = colonne.id !== COLONNE_SELECTION && !colonne.getIsPinned();
|
|
303
|
+
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: colonne.id, disabled: !deplacable });
|
|
304
|
+
// Tout l'en-tête se saisit, comme dans un tableur : viser une poignée de
|
|
305
|
+
// dix pixels n'est pas un geste qu'on devine. Le clic simple TRIE toujours,
|
|
306
|
+
// le seuil de 4 px du capteur les départage. Le CLAVIER, lui, passe par la
|
|
307
|
+
// poignée : c'est le seul élément focusable qui porte ce rôle.
|
|
308
|
+
const { onPointerDown, onKeyDown } = (listeners ?? {});
|
|
309
|
+
// Au lâcher, le navigateur émet un clic sur ce qui est sous le pointeur :
|
|
310
|
+
// un intitulé, qui trierait. On l'avale pendant un instant, sur TOUS les
|
|
311
|
+
// en-têtes, puisque la colonne peut être posée sur n'importe lequel.
|
|
312
|
+
const finDeplacement = useRef(0);
|
|
313
|
+
useDndMonitor({
|
|
314
|
+
onDragEnd: () => {
|
|
315
|
+
finDeplacement.current = Date.now();
|
|
316
|
+
},
|
|
317
|
+
onDragCancel: () => {
|
|
318
|
+
finDeplacement.current = Date.now();
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const style = {
|
|
322
|
+
...p.style,
|
|
323
|
+
transform: transform ? `translate3d(${Math.round(transform.x)}px, 0, 0)` : undefined,
|
|
324
|
+
transition,
|
|
325
|
+
};
|
|
326
|
+
return (_jsxs("div", { ref: setNodeRef, className: cx(p.className, 'zv-colhead', deplacable && 'zv-colhead--deplacable', isDragging && 'zv-colhead--drag', colonne.columnDef.meta?.classe), style: style, "aria-sort": p['aria-sort'], onPointerDown: deplacable ? onPointerDown : undefined, onClickCapture: (e) => {
|
|
327
|
+
if (Date.now() - finDeplacement.current < 250) {
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
e.stopPropagation();
|
|
330
|
+
}
|
|
331
|
+
}, children: [deplacable && (_jsx("button", { type: "button", ref: setActivatorNodeRef, className: "zv-colhead__grip", ...attributes, onKeyDown: onKeyDown, "aria-label": t.deplacer(nom), children: _jsx(Icon, { nom: "menu", taille: "sm" }) })), p.children, colonne.getCanResize() && _jsx(PoigneeLargeur, { colonne: colonne, table: table, nom: nom, t: t })] }));
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Le redimensionnement est ÉCRIT ICI, pas confié à `header.getResizeHandler()` :
|
|
335
|
+
* celui-ci part de `getSize()`, qui vaut le plancher (150 px) pour une colonne
|
|
336
|
+
* souple quelle que soit sa largeur à l'écran, et le premier geste faisait
|
|
337
|
+
* sauter la colonne. On mesure la largeur RÉELLE à l'appui, puis on écrit
|
|
338
|
+
* `columnSizing` à chaque mouvement : c'est le mode `onChange`.
|
|
339
|
+
*/
|
|
340
|
+
function PoigneeLargeur({ colonne, table, nom, t, }) {
|
|
341
|
+
const ref = useRef(null);
|
|
342
|
+
const depart = useRef(null);
|
|
343
|
+
const [actif, setActif] = useState(false);
|
|
344
|
+
const min = colonne.columnDef.minSize ?? 60;
|
|
345
|
+
const max = colonne.columnDef.maxSize ?? Number.MAX_SAFE_INTEGER;
|
|
346
|
+
const largeurActuelle = () => table.getState().columnSizing[colonne.id] ??
|
|
347
|
+
ref.current?.parentElement?.getBoundingClientRect().width ??
|
|
348
|
+
colonne.getSize();
|
|
349
|
+
const regler = (largeur) => {
|
|
350
|
+
const l = Math.round(Math.min(max, Math.max(min, largeur)));
|
|
351
|
+
table.setColumnSizing((ancien) => ({ ...ancien, [colonne.id]: l }));
|
|
352
|
+
};
|
|
353
|
+
const rendre = () => table.setColumnSizing((ancien) => {
|
|
354
|
+
const { [colonne.id]: _retire, ...reste } = ancien;
|
|
355
|
+
return reste;
|
|
356
|
+
});
|
|
357
|
+
const appui = (e) => {
|
|
358
|
+
if (e.button !== 0)
|
|
359
|
+
return;
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
e.stopPropagation();
|
|
362
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
363
|
+
depart.current = { x: e.clientX, largeur: largeurActuelle() };
|
|
364
|
+
setActif(true);
|
|
365
|
+
};
|
|
366
|
+
const mouvement = (e) => {
|
|
367
|
+
if (!depart.current)
|
|
368
|
+
return;
|
|
369
|
+
regler(depart.current.largeur + e.clientX - depart.current.x);
|
|
370
|
+
};
|
|
371
|
+
const fin = (e) => {
|
|
372
|
+
if (!depart.current)
|
|
373
|
+
return;
|
|
374
|
+
depart.current = null;
|
|
375
|
+
setActif(false);
|
|
376
|
+
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
377
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
378
|
+
};
|
|
379
|
+
const clavier = (e) => {
|
|
380
|
+
const pas = e.shiftKey ? 50 : 10;
|
|
381
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
|
382
|
+
e.preventDefault();
|
|
383
|
+
regler(largeurActuelle() + (e.key === 'ArrowRight' ? pas : -pas));
|
|
384
|
+
}
|
|
385
|
+
else if (e.key === 'Enter' || e.key === 'Delete' || e.key === 'Backspace') {
|
|
386
|
+
e.preventDefault();
|
|
387
|
+
rendre();
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const reglee = table.getState().columnSizing[colonne.id];
|
|
391
|
+
return (_jsx("span", { ref: ref,
|
|
392
|
+
// Le motif ARIA du séparateur DÉPLAÇABLE (window splitter) : focusable,
|
|
393
|
+
// une valeur, et les flèches pour la changer.
|
|
394
|
+
role: "separator", "aria-orientation": "vertical", "aria-label": t.largeur(nom), "aria-valuenow": Math.round(reglee ?? colonne.getSize()), "aria-valuemin": min, tabIndex: 0, title: t.largeurAide, className: cx('zv-colhead__resize', actif && 'zv-colhead__resize--actif'), onPointerDown: appui, onPointerMove: mouvement, onPointerUp: fin, onPointerCancel: fin, onDoubleClick: rendre, onKeyDown: clavier, onClick: (e) => e.stopPropagation() }));
|
|
395
|
+
}
|
|
396
|
+
/* ─── Cases de sélection ─────────────────────────────────────────────── */
|
|
397
|
+
function CaseTout({ table, t }) {
|
|
398
|
+
// Une enveloppe plutôt qu'une ref sur la case : `Checkbox` ne transmet
|
|
399
|
+
// pas de ref sous React 18, que le paquet accepte encore.
|
|
400
|
+
const ref = useRef(null);
|
|
401
|
+
const partiel = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected();
|
|
402
|
+
// `indeterminate` n'a pas d'attribut HTML : seule la propriété DOM existe.
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
const caseTout = ref.current?.querySelector('input');
|
|
405
|
+
if (caseTout)
|
|
406
|
+
caseTout.indeterminate = partiel;
|
|
407
|
+
}, [partiel]);
|
|
408
|
+
return (_jsx("span", { ref: ref, className: "zv-cell--case", children: _jsx(Checkbox, { checked: table.getIsAllRowsSelected(), onChange: table.getToggleAllRowsSelectedHandler(), "aria-label": t.toutSelectionner }) }));
|
|
409
|
+
}
|
|
410
|
+
function colonneSelection(t) {
|
|
411
|
+
return {
|
|
412
|
+
id: COLONNE_SELECTION,
|
|
413
|
+
size: 40,
|
|
414
|
+
minSize: 40,
|
|
415
|
+
enableSorting: false,
|
|
416
|
+
enableHiding: false,
|
|
417
|
+
enableResizing: false,
|
|
418
|
+
enableColumnFilter: false,
|
|
419
|
+
enableGlobalFilter: false,
|
|
420
|
+
enablePinning: false,
|
|
421
|
+
header: ({ table }) => _jsx(CaseTout, { table: table, t: t }),
|
|
422
|
+
cell: ({ row }) => (_jsx(Checkbox, { checked: row.getIsSelected(), disabled: !row.getCanSelect(), onChange: row.getToggleSelectedHandler(),
|
|
423
|
+
// La case vit dans une ligne cliquable : sans ces deux arrêts, cocher
|
|
424
|
+
// ouvrirait aussi la ligne, et Espace l'ouvrirait au clavier.
|
|
425
|
+
onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), "aria-label": t.selectionnerLigne })),
|
|
426
|
+
meta: { classe: 'zv-cell--case' },
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/* ─── Menu des colonnes : visibilité et épinglage ────────────────────── */
|
|
430
|
+
function MenuColonnes({ table, t }) {
|
|
431
|
+
const [ouvert, setOuvert] = useState(false);
|
|
432
|
+
const ancre = useRef(null);
|
|
433
|
+
// Fermeture au clic extérieur et à Échap, focus rendu au déclencheur :
|
|
434
|
+
// c'est à l'appelant de le faire, le Menu du paquet ne fait qu'afficher.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
if (!ouvert)
|
|
437
|
+
return;
|
|
438
|
+
const document_ = ancre.current?.ownerDocument ?? document;
|
|
439
|
+
const dehors = (e) => {
|
|
440
|
+
if (!ancre.current?.contains(e.target))
|
|
441
|
+
setOuvert(false);
|
|
442
|
+
};
|
|
443
|
+
const echap = (e) => {
|
|
444
|
+
if (e.key === 'Escape') {
|
|
445
|
+
setOuvert(false);
|
|
446
|
+
ancre.current?.querySelector('button')?.focus();
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
document_.addEventListener('mousedown', dehors);
|
|
450
|
+
document_.addEventListener('keydown', echap);
|
|
451
|
+
return () => {
|
|
452
|
+
document_.removeEventListener('mousedown', dehors);
|
|
453
|
+
document_.removeEventListener('keydown', echap);
|
|
454
|
+
};
|
|
455
|
+
}, [ouvert]);
|
|
456
|
+
const colonnes = table.getAllLeafColumns().filter((c) => c.id !== COLONNE_SELECTION);
|
|
457
|
+
return (_jsx("div", { ref: ancre, children: _jsxs(SelectAnchor, { children: [_jsx(Button, { variante: "fantome", compact: true, icone: "reglages", "aria-expanded": ouvert, "aria-haspopup": "true", onClick: () => setOuvert((o) => !o), children: t.colonnes }), ouvert && (_jsx(Menu, { role: "group", "aria-label": t.colonnesAffichees, className: "zv-grille__menu", children: colonnes.map((c) => {
|
|
458
|
+
const nom = libelleColonne(c);
|
|
459
|
+
const cote = c.getIsPinned();
|
|
460
|
+
const suivant = cote === false ? 'left' : cote === 'left' ? 'right' : false;
|
|
461
|
+
return (_jsxs("div", { className: "zv-grille__menu-ligne", children: [_jsxs("label", { className: "zv-menu__item zv-grille__menu-case", children: [_jsx(Checkbox, { checked: c.getIsVisible(), disabled: !c.getCanHide(), onChange: c.getToggleVisibilityHandler() }), _jsx("span", { children: nom }), cote && (_jsx(Meta, { taille: "sm", children: cote === 'left' ? t.aGauche : t.aDroite }))] }), c.getCanPin() && (_jsx(IconButton, { icone: "epingle", taille: "sm", className: "zv-grille__epingle", "aria-pressed": cote !== false, label: suivant === 'left'
|
|
462
|
+
? t.epinglerGauche(nom)
|
|
463
|
+
: suivant === 'right'
|
|
464
|
+
? t.epinglerDroite(nom)
|
|
465
|
+
: t.detacher(nom), onClick: () => c.pin(suivant) }))] }, c.id));
|
|
466
|
+
}) }))] }) }));
|
|
467
|
+
}
|
|
468
|
+
/* ─── Filtres de colonne ────────────────────────────────────────────── */
|
|
469
|
+
function FiltreColonne({ colonne, type, t, }) {
|
|
470
|
+
const nom = libelleColonne(colonne);
|
|
471
|
+
const filtre = colonne.getFilterValue();
|
|
472
|
+
if (type === 'choix') {
|
|
473
|
+
// Les valeurs du modèle FACETTÉ : celles que les autres filtres laissent
|
|
474
|
+
// encore passer. Une option qui ne rendrait aucune ligne n'est pas offerte.
|
|
475
|
+
const options = [...colonne.getFacetedUniqueValues().keys()]
|
|
476
|
+
.filter((v) => v != null && v !== '')
|
|
477
|
+
.map(String)
|
|
478
|
+
.sort((a, b) => a.localeCompare(b, 'fr'));
|
|
479
|
+
const valeur = filtre?.type === 'choix' ? filtre.valeur : '';
|
|
480
|
+
// L'option choisie reste dans la liste même si un autre filtre l'a
|
|
481
|
+
// fait disparaître : sinon le <select> afficherait « Tous » en mentant.
|
|
482
|
+
if (valeur && !options.includes(valeur))
|
|
483
|
+
options.unshift(valeur);
|
|
484
|
+
return (_jsxs(Select, { className: "zv-table__filtre", value: valeur, onChange: (e) => colonne.setFilterValue({ type: 'choix', valeur: e.target.value }), "aria-label": t.filtrerColonne(nom), children: [_jsx("option", { value: "", children: t.tous }), options.map((o) => (_jsx("option", { value: o, children: o }, o)))] }));
|
|
485
|
+
}
|
|
486
|
+
if (type === 'nombre' || type === 'date') {
|
|
487
|
+
return _jsx(FiltrePlage, { colonne: colonne, type: type, nom: nom, t: t });
|
|
488
|
+
}
|
|
489
|
+
return (_jsx(Input, { className: "zv-table__filtre", value: filtre?.type === 'texte' ? filtre.valeur : '', onChange: (e) => colonne.setFilterValue({ type: 'texte', valeur: e.target.value }), placeholder: t.filtrer, "aria-label": t.filtrerColonne(nom) }));
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Une plage (deux bornes) ne tient pas dans une cellule de 90 px : le champ
|
|
493
|
+
* de la rangée RÉSUME la plage, et l'ouvre dans un popover.
|
|
494
|
+
*
|
|
495
|
+
* ⚠️ LE POPOVER SORT DU CADRE, par un portail. `.zv-table` défile
|
|
496
|
+
* horizontalement, et `overflow-x: auto` force `overflow-y` : un popover
|
|
497
|
+
* posé dans la cellule serait rogné par le bas du tableau, invisible sur un
|
|
498
|
+
* tableau de trois lignes. Il est donc rendu dans le <body> et placé sous
|
|
499
|
+
* son déclencheur, en coordonnées d'écran.
|
|
500
|
+
*/
|
|
501
|
+
function FiltrePlage({ colonne, type, nom, t, }) {
|
|
502
|
+
const [ouvert, setOuvert] = useState(false);
|
|
503
|
+
const [position, setPosition] = useState(null);
|
|
504
|
+
const declencheur = useRef(null);
|
|
505
|
+
const panneau = useRef(null);
|
|
506
|
+
const idChamp = useId();
|
|
507
|
+
const filtre = colonne.getFilterValue();
|
|
508
|
+
const [a, b] = filtre?.type === 'nombre' ? [filtre.min, filtre.max] : filtre?.type === 'date' ? [filtre.du, filtre.au] : ['', ''];
|
|
509
|
+
const ecrire = (bas, haut) => colonne.setFilterValue(type === 'nombre' ? { type, min: bas, max: haut } : { type, du: bas, au: haut });
|
|
510
|
+
// Ce qui est TAPÉ, distinct de ce qui est APPLIQUÉ : une date en cours de
|
|
511
|
+
// saisie s'affiche dans le champ sans filtrer le tableau. Resynchronisé
|
|
512
|
+
// quand le filtre change d'ailleurs (« Réinitialiser la vue »).
|
|
513
|
+
const [brouillon, setBrouillon] = useState([a, b]);
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
setBrouillon([a, b]);
|
|
516
|
+
}, [a, b]);
|
|
517
|
+
const saisir = (bas, haut) => {
|
|
518
|
+
setBrouillon([bas, haut]);
|
|
519
|
+
if (type === 'nombre' || (dateComplete(bas) && dateComplete(haut)))
|
|
520
|
+
ecrire(bas, haut);
|
|
521
|
+
};
|
|
522
|
+
const resume = type === 'nombre' ? t.resumeNombres(a, b) : t.resumeDates(a, b);
|
|
523
|
+
const vide = !a && !b;
|
|
524
|
+
// Placé AVANT l'affichage (layout effect) : sinon le popover apparaît un
|
|
525
|
+
// instant en haut à gauche de la page.
|
|
526
|
+
useLayoutEffect(() => {
|
|
527
|
+
if (!ouvert) {
|
|
528
|
+
setPosition(null);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const placer = () => {
|
|
532
|
+
const r = declencheur.current?.getBoundingClientRect();
|
|
533
|
+
if (!r)
|
|
534
|
+
return;
|
|
535
|
+
// Le panneau est absolu dans son enveloppe fixe : c'est LUI qu'on
|
|
536
|
+
// mesure, l'enveloppe n'a pas de largeur.
|
|
537
|
+
const largeur = panneau.current?.firstElementChild?.offsetWidth ?? 0;
|
|
538
|
+
const vue = declencheur.current?.ownerDocument.defaultView;
|
|
539
|
+
const droite = vue ? vue.innerWidth : r.right + largeur;
|
|
540
|
+
// Le décalage de 5 px sous le déclencheur est celui de .zv-popover
|
|
541
|
+
// (top: calc(100% + 5px)) : l'enveloppe se pose au ras du champ.
|
|
542
|
+
setPosition({ top: r.bottom, left: Math.max(0, Math.min(r.left, droite - largeur - 8)) });
|
|
543
|
+
};
|
|
544
|
+
placer();
|
|
545
|
+
const vue = declencheur.current?.ownerDocument.defaultView;
|
|
546
|
+
vue?.addEventListener('resize', placer);
|
|
547
|
+
// `true` : le défilement du CADRE du tableau, pas seulement de la page.
|
|
548
|
+
vue?.addEventListener('scroll', placer, true);
|
|
549
|
+
return () => {
|
|
550
|
+
vue?.removeEventListener('resize', placer);
|
|
551
|
+
vue?.removeEventListener('scroll', placer, true);
|
|
552
|
+
};
|
|
553
|
+
}, [ouvert]);
|
|
554
|
+
useEffect(() => {
|
|
555
|
+
if (!ouvert)
|
|
556
|
+
return;
|
|
557
|
+
const doc = declencheur.current?.ownerDocument ?? document;
|
|
558
|
+
const dehors = (e) => {
|
|
559
|
+
const cible = e.target;
|
|
560
|
+
if (!panneau.current?.contains(cible) && !declencheur.current?.contains(cible))
|
|
561
|
+
setOuvert(false);
|
|
562
|
+
};
|
|
563
|
+
const echap = (e) => {
|
|
564
|
+
if (e.key === 'Escape') {
|
|
565
|
+
setOuvert(false);
|
|
566
|
+
declencheur.current?.focus();
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
doc.addEventListener('mousedown', dehors);
|
|
570
|
+
doc.addEventListener('keydown', echap);
|
|
571
|
+
return () => {
|
|
572
|
+
doc.removeEventListener('mousedown', dehors);
|
|
573
|
+
doc.removeEventListener('keydown', echap);
|
|
574
|
+
};
|
|
575
|
+
}, [ouvert]);
|
|
576
|
+
// Le focus entre dans le premier champ une fois le panneau PLACÉ : avant,
|
|
577
|
+
// il est en `visibility: hidden`, et le navigateur refuse le focus à un
|
|
578
|
+
// élément invisible. La frappe partait alors dans le vide.
|
|
579
|
+
const place = position !== null;
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (ouvert && place)
|
|
582
|
+
panneau.current?.querySelector('input')?.focus();
|
|
583
|
+
}, [ouvert, place]);
|
|
584
|
+
const champ = (valeur, changer, suffixe, bornes) => type === 'date' ? (_jsx(Input, { id: idChamp + suffixe, type: "date", value: valeur,
|
|
585
|
+
// Le calendrier natif grise ce qui rendrait la plage incohérente :
|
|
586
|
+
// « Au » ne peut pas précéder « Du ».
|
|
587
|
+
min: bornes.min || undefined, max: bornes.max || undefined, onChange: (e) => changer(e.target.value) })) : (_jsx(Input, { id: idChamp + suffixe,
|
|
588
|
+
// Pas `type="number"` : il accepte « e », « + » et refuse la virgule
|
|
589
|
+
// française selon la langue du système. Le clavier numérique vient
|
|
590
|
+
// d'`inputMode`, le refus des lettres de `saisieNombre`.
|
|
591
|
+
inputMode: "decimal", autoComplete: "off", value: valeur, onChange: (e) => changer(saisieNombre(e.target.value)) }));
|
|
592
|
+
return (_jsxs(_Fragment, { children: [_jsxs("button", { ref: declencheur, type: "button", className: "zv-input zv-input--trigger zv-table__filtre", "data-vide": vide ? 'oui' : undefined, "aria-expanded": ouvert, "aria-haspopup": "dialog", "aria-label": `${t.filtrerColonne(nom)} : ${resume}`, onClick: () => setOuvert((o) => !o), children: [_jsx("span", { className: "zv-colhead__label", children: resume }), _jsx(Icon, { nom: type === 'date' ? 'calendrier' : 'filtre', taille: "sm", discret: true })] }), ouvert &&
|
|
593
|
+
createPortal(_jsx("div", { ref: panneau, role: "dialog", "aria-label": t.filtrerColonne(nom), className: "zv-grille__flottant", style: position ? { top: position.top, left: position.left } : { visibility: 'hidden' }, children: _jsxs(Popover, { className: "zv-grille__plage", children: [type === 'date' && (_jsx("div", { className: "zv-grille__periodes", role: "group", "aria-label": t.periodes, children: ['aujourdhui', 'septJours', 'trenteJours', 'ceMois', 'cetteAnnee'].map((cle) => {
|
|
594
|
+
const [du, au] = periodes(new Date())[cle];
|
|
595
|
+
const actif = a === du && b === au;
|
|
596
|
+
return (_jsx(MenuItem, { actif: actif, onClick: () => saisir(du, au), children: t[cle] }, cle));
|
|
597
|
+
}) })), _jsxs("div", { className: "zv-grille__bornes", children: [_jsx(Field, { label: type === 'date' ? t.du : t.min, htmlFor: idChamp + 'a', children: champ(brouillon[0], (v) => saisir(v, brouillon[1]), 'a', { max: brouillon[1] }) }), _jsx(Field, { label: type === 'date' ? t.au : t.max, htmlFor: idChamp + 'b', children: champ(brouillon[1], (v) => saisir(brouillon[0], v), 'b', { min: brouillon[0] }) })] }), !vide && (_jsx(Button, { variante: "fantome", compact: true, icone: "fermer", onClick: () => saisir('', ''), children: t.effacer }))] }) }), declencheur.current?.ownerDocument.body ?? document.body)] }));
|
|
598
|
+
}
|
|
599
|
+
/* ─── La grille ──────────────────────────────────────────────────────── */
|
|
600
|
+
export function DataGrid({ tableId, data, columns, onRowClick, getRowId, selection = false, onSelectionChange, parPage = 25, recherche = true, filtres = true, exportCsv = true, vueInitiale, vide, outils, textes, className, }) {
|
|
601
|
+
const t = { ...TEXTES_GRILLE, ...textes };
|
|
602
|
+
const ids = useMemo(() => columns.map((c) => idDeColonne(c)).filter((id) => id != null), [columns]);
|
|
603
|
+
const cleIds = ids.join('\u0000');
|
|
604
|
+
// La vue par défaut ne change qu'avec les colonnes : stable sinon, pour
|
|
605
|
+
// que le hook ne la prenne pas pour une vue nouvelle à chaque rendu.
|
|
606
|
+
const defaut = useMemo(() => ({
|
|
607
|
+
columnOrder: ids,
|
|
608
|
+
columnVisibility: Object.fromEntries((vueInitiale?.masquees ?? []).map((id) => [id, false])),
|
|
609
|
+
columnSizing: {},
|
|
610
|
+
columnPinning: {
|
|
611
|
+
left: vueInitiale?.epingleesGauche ?? [],
|
|
612
|
+
right: vueInitiale?.epingleesDroite ?? [],
|
|
613
|
+
},
|
|
614
|
+
sorting: vueInitiale?.tri ?? [],
|
|
615
|
+
pageSize: parPage || TAILLES_PAGE[0],
|
|
616
|
+
}),
|
|
617
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
618
|
+
[cleIds, JSON.stringify(vueInitiale ?? null), parPage]);
|
|
619
|
+
const reconcilier = useCallback((lu, d) => reconcilierVue(lu, d, ids),
|
|
620
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
621
|
+
[cleIds]);
|
|
622
|
+
const [vue, setVue, reinitialiserVue] = usePersistedState(`table:${tableId}:v1`, defaut, reconcilier);
|
|
623
|
+
const [columnFilters, setColumnFilters] = useState([]);
|
|
624
|
+
const [globalFilter, setGlobalFilter] = useState('');
|
|
625
|
+
const [rowSelection, setRowSelection] = useState({});
|
|
626
|
+
const [pageIndex, setPageIndex] = useState(0);
|
|
627
|
+
const champ = (k) => (u) => setVue((v) => ({ ...v, [k]: appliquer(u, v[k]) }));
|
|
628
|
+
// La colonne de cases est épinglée à gauche, toujours, et n'entre pas dans
|
|
629
|
+
// la vue enregistrée : elle dépend de la prop, pas de l'utilisateur.
|
|
630
|
+
const pinning = {
|
|
631
|
+
left: selection ? [COLONNE_SELECTION, ...(vue.columnPinning.left ?? [])] : vue.columnPinning.left,
|
|
632
|
+
right: vue.columnPinning.right,
|
|
633
|
+
};
|
|
634
|
+
const pagination = {
|
|
635
|
+
pageIndex,
|
|
636
|
+
pageSize: parPage === false ? Math.max(data.length, 1) : vue.pageSize,
|
|
637
|
+
};
|
|
638
|
+
const colonnes = useMemo(() => {
|
|
639
|
+
const avecFiltre = columns.map((c) => (c.filterFn ? c : { ...c, filterFn: filtreGrille }));
|
|
640
|
+
return selection ? [colonneSelection(t), ...avecFiltre] : avecFiltre;
|
|
641
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
642
|
+
}, [columns, selection, t.toutSelectionner, t.selectionnerLigne]);
|
|
643
|
+
const table = useReactTable({
|
|
644
|
+
data,
|
|
645
|
+
columns: colonnes,
|
|
646
|
+
getRowId,
|
|
647
|
+
state: {
|
|
648
|
+
columnOrder: vue.columnOrder,
|
|
649
|
+
columnVisibility: vue.columnVisibility,
|
|
650
|
+
columnSizing: vue.columnSizing,
|
|
651
|
+
columnPinning: pinning,
|
|
652
|
+
sorting: vue.sorting,
|
|
653
|
+
columnFilters,
|
|
654
|
+
globalFilter,
|
|
655
|
+
rowSelection,
|
|
656
|
+
pagination,
|
|
657
|
+
},
|
|
658
|
+
defaultColumn: { size: 150, minSize: 60 },
|
|
659
|
+
onColumnOrderChange: champ('columnOrder'),
|
|
660
|
+
onColumnVisibilityChange: champ('columnVisibility'),
|
|
661
|
+
onColumnSizingChange: champ('columnSizing'),
|
|
662
|
+
onSortingChange: champ('sorting'),
|
|
663
|
+
onColumnPinningChange: (u) => setVue((v) => {
|
|
664
|
+
const actuel = {
|
|
665
|
+
left: selection ? [COLONNE_SELECTION, ...(v.columnPinning.left ?? [])] : v.columnPinning.left,
|
|
666
|
+
right: v.columnPinning.right,
|
|
667
|
+
};
|
|
668
|
+
const n = appliquer(u, actuel);
|
|
669
|
+
return {
|
|
670
|
+
...v,
|
|
671
|
+
columnPinning: {
|
|
672
|
+
left: (n.left ?? []).filter((id) => id !== COLONNE_SELECTION),
|
|
673
|
+
right: (n.right ?? []).filter((id) => id !== COLONNE_SELECTION),
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}),
|
|
677
|
+
onColumnFiltersChange: setColumnFilters,
|
|
678
|
+
onGlobalFilterChange: setGlobalFilter,
|
|
679
|
+
onRowSelectionChange: setRowSelection,
|
|
680
|
+
onPaginationChange: (u) => {
|
|
681
|
+
const n = appliquer(u, pagination);
|
|
682
|
+
setPageIndex(n.pageIndex);
|
|
683
|
+
if (parPage !== false && n.pageSize !== vue.pageSize) {
|
|
684
|
+
setVue((v) => ({ ...v, pageSize: n.pageSize }));
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
enableRowSelection: selection,
|
|
688
|
+
enableMultiSort: true,
|
|
689
|
+
enableColumnResizing: true,
|
|
690
|
+
columnResizeMode: 'onChange',
|
|
691
|
+
enableColumnPinning: true,
|
|
692
|
+
globalFilterFn: 'includesString',
|
|
693
|
+
getCoreRowModel: getCoreRowModel(),
|
|
694
|
+
getSortedRowModel: getSortedRowModel(),
|
|
695
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
696
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
697
|
+
getFacetedRowModel: getFacetedRowModel(),
|
|
698
|
+
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
699
|
+
});
|
|
700
|
+
// La sélection se remonte en LIGNES, pas en identifiants : c'est ce que
|
|
701
|
+
// l'app veut traiter (supprimer, exporter, assigner).
|
|
702
|
+
const surSelection = useRef(onSelectionChange);
|
|
703
|
+
surSelection.current = onSelectionChange;
|
|
704
|
+
useEffect(() => {
|
|
705
|
+
surSelection.current?.(table.getSelectedRowModel().rows.map((r) => r.original));
|
|
706
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
707
|
+
}, [rowSelection]);
|
|
708
|
+
// Une page au-delà de la dernière (filtre resserré, données rechargées)
|
|
709
|
+
// afficherait la ligne d'absence alors que des lignes existent.
|
|
710
|
+
const nbPages = table.getPageCount();
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
if (pageIndex > 0 && pageIndex >= nbPages)
|
|
713
|
+
setPageIndex(Math.max(0, nbPages - 1));
|
|
714
|
+
}, [pageIndex, nbPages]);
|
|
715
|
+
const reinitialiser = () => {
|
|
716
|
+
reinitialiserVue();
|
|
717
|
+
setColumnFilters([]);
|
|
718
|
+
setGlobalFilter('');
|
|
719
|
+
setPageIndex(0);
|
|
720
|
+
};
|
|
721
|
+
/* Glisser-déposer des en-têtes. */
|
|
722
|
+
const idDnd = useId();
|
|
723
|
+
const capteurs = useSensors(
|
|
724
|
+
// 4 px avant de saisir : sans ce seuil, un simple clic sur la poignée
|
|
725
|
+
// démarre un déplacement.
|
|
726
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
727
|
+
const deplacables = table.getCenterVisibleLeafColumns().map((c) => c.id);
|
|
728
|
+
const nomDe = (id) => {
|
|
729
|
+
const c = table.getColumn(String(id));
|
|
730
|
+
return c ? libelleColonne(c) : String(id);
|
|
731
|
+
};
|
|
732
|
+
const annonces = {
|
|
733
|
+
onDragStart: ({ active }) => t.dndSaisie(nomDe(active.id)),
|
|
734
|
+
onDragOver: ({ active, over }) => over ? t.dndSurvol(nomDe(active.id), nomDe(over.id)) : t.dndHors(nomDe(active.id)),
|
|
735
|
+
onDragEnd: ({ active, over }) => over ? t.dndPose(nomDe(active.id), nomDe(over.id)) : t.dndReposee(nomDe(active.id)),
|
|
736
|
+
onDragCancel: ({ active }) => t.dndAnnule(nomDe(active.id)),
|
|
737
|
+
};
|
|
738
|
+
const finDeplacement = ({ active, over }) => {
|
|
739
|
+
if (!over || active.id === over.id)
|
|
740
|
+
return;
|
|
741
|
+
table.setColumnOrder((ordre) => {
|
|
742
|
+
const depuis = ordre.indexOf(String(active.id));
|
|
743
|
+
const vers = ordre.indexOf(String(over.id));
|
|
744
|
+
return depuis < 0 || vers < 0 ? ordre : arrayMove(ordre, depuis, vers);
|
|
745
|
+
});
|
|
746
|
+
};
|
|
747
|
+
/* Rangée de filtres. La famille de chaque colonne se déduit des données
|
|
748
|
+
* BRUTES (le modèle de base, pas le filtré) : sinon un filtre qui ne laisse
|
|
749
|
+
* qu'une valeur ferait changer la colonne de famille sous le doigt. */
|
|
750
|
+
const lignesBase = table.getCoreRowModel().flatRows;
|
|
751
|
+
const types = useMemo(() => {
|
|
752
|
+
const sortie = {};
|
|
753
|
+
for (const c of table.getAllLeafColumns()) {
|
|
754
|
+
if (c.id === COLONNE_SELECTION)
|
|
755
|
+
continue;
|
|
756
|
+
sortie[c.id] = typeDeFiltre(c.columnDef.meta, lignesBase.map((l) => l.getValue(c.id)));
|
|
757
|
+
}
|
|
758
|
+
return sortie;
|
|
759
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
760
|
+
}, [lignesBase, colonnes]);
|
|
761
|
+
const filtrables = table.getVisibleLeafColumns().some((c) => c.getCanFilter() && types[c.id]);
|
|
762
|
+
const rangeeFiltres = filtres && filtrables ? (_jsx("div", { className: "zv-table__head zv-table__head--filtres", children: colonnesAffichees(table).map((c) => {
|
|
763
|
+
const pin = epinglage(c, table);
|
|
764
|
+
const type = c.getCanFilter() ? types[c.id] : false;
|
|
765
|
+
return (_jsx("div", { className: cx('zv-cell', pin.classes), style: pin.style, children: type && _jsx(FiltreColonne, { colonne: c, type: type, t: t }) }, c.id));
|
|
766
|
+
}) })) : null;
|
|
767
|
+
const total = table.getPrePaginationRowModel().rows.length;
|
|
768
|
+
const { pageIndex: page, pageSize } = table.getState().pagination;
|
|
769
|
+
const debut = total === 0 ? 0 : page * pageSize + 1;
|
|
770
|
+
const fin = Math.min(total, (page + 1) * pageSize);
|
|
771
|
+
const nbChoisies = Object.keys(rowSelection).length;
|
|
772
|
+
const filtre = columnFilters.length > 0 || globalFilter !== '';
|
|
773
|
+
return (_jsxs("div", { className: cx('zv-grille', className), children: [_jsxs("div", { className: "zv-grille__outils", children: [recherche ? (_jsx(Search, { value: globalFilter, onChange: (e) => setGlobalFilter(e.target.value), placeholder: t.rechercher, "aria-label": t.rechercher })) : (_jsx("span", {})), _jsxs("div", { className: "zv-grille__actions", children: [selection && nbChoisies > 0 && (_jsx(Meta, { children: t.choisies(nbChoisies) })), outils, _jsx(MenuColonnes, { table: table, t: t }), exportCsv !== false && (_jsx(Button, { variante: "fantome", compact: true, icone: "telecharger", onClick: () => telecharger(versCsv(table), typeof exportCsv === 'string' ? exportCsv : tableId), children: t.exporter })), _jsx(Button, { variante: "fantome", compact: true, icone: "rembobiner", onClick: reinitialiser, children: t.reinitialiser })] })] }), _jsx(DndContext, { id: idDnd, sensors: capteurs, collisionDetection: closestCenter, modifiers: [axeHorizontal], onDragEnd: finDeplacement, accessibility: {
|
|
774
|
+
announcements: annonces,
|
|
775
|
+
screenReaderInstructions: {
|
|
776
|
+
draggable: t.dndInstructions,
|
|
777
|
+
},
|
|
778
|
+
}, children: _jsx(SortableContext, { items: deplacables, strategy: horizontalListSortingStrategy, children: _jsx(DataTable, { table: table, className: "zv-table--grille", celluleEntete: (p) => _jsx(EnteteColonne, { ...p, t: t }), sousEntete: rangeeFiltres, onRowClick: onRowClick, vide: vide ?? (_jsx(Empty, { children: data.length === 0 ? t.aucuneDonnee : filtre ? t.aucunResultat : t.aucuneLigne })) }) }) }), parPage !== false && (_jsxs(Pager, { pied: true, "aria-label": t.pagination, children: [_jsx(Meta, { children: t.compte(debut, fin, total) }), _jsxs("div", { className: "zv-grille__pied", children: [_jsx(Select, { className: "zv-grille__taille", "aria-label": t.lignesParPage, value: pageSize, onChange: (e) => table.setPageSize(Number(e.target.value)), children: TAILLES_PAGE.map((taille) => (_jsx("option", { value: taille, children: t.parPage(taille) }, taille))) }), _jsx(PagerArrow, { "aria-label": t.pagePrecedente, desactive: !table.getCanPreviousPage(), onClick: () => table.getCanPreviousPage() && table.previousPage(), children: "\u2039" }), _jsx(Meta, { children: t.page(nbPages === 0 ? 0 : page + 1, nbPages) }), _jsx(PagerArrow, { "aria-label": t.pageSuivante, desactive: !table.getCanNextPage(), onClick: () => table.getCanNextPage() && table.nextPage(), children: "\u203A" })] })] }))] }));
|
|
779
|
+
}
|
|
780
|
+
//# sourceMappingURL=grille.js.map
|