@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.
@@ -0,0 +1,105 @@
1
+ 'use client'
2
+
3
+ // Un état React qui survit au rechargement, dans `localStorage`.
4
+ //
5
+ // ⚠️ LA LECTURE A LIEU APRÈS LE MONTAGE, et c'est tout l'objet du hook. Lue
6
+ // pendant le rendu, la valeur différerait entre le serveur (qui n'a pas de
7
+ // `localStorage`) et le navigateur : React signale alors une erreur
8
+ // d'hydratation et Next jette le rendu serveur. Le premier rendu montre donc
9
+ // `initial`, puis la valeur enregistrée.
10
+ //
11
+ // ⚠️ TOUT ACCÈS EST SOUS try/catch : navigation privée, quota plein, stockage
12
+ // bloqué par l'utilisateur ou une politique d'entreprise, JSON abîmé à la
13
+ // main. Dans chacun de ces cas l'état fonctionne, il ne persiste simplement
14
+ // pas. Un tableau qui plante parce que Safari refuse d'écrire est pire qu'un
15
+ // tableau qui oublie sa vue.
16
+
17
+ import { useCallback, useEffect, useRef, useState } from 'react'
18
+ import type { Dispatch, SetStateAction } from 'react'
19
+
20
+ /**
21
+ * Comme `useState`, mais enregistré sous `key`.
22
+ *
23
+ * `reconcilier` reçoit la valeur LUE (non typée : elle vient d'une version
24
+ * précédente du code, ou d'ailleurs) et la valeur initiale ; il rend la
25
+ * valeur à employer, ou `undefined` pour s'en tenir à l'initiale. C'est là
26
+ * qu'une vue enregistrée s'adapte à des colonnes qui ont changé.
27
+ *
28
+ * Rend `[valeur, setValeur, reinitialiser]` : `reinitialiser` vide la clé et
29
+ * revient à `initial`.
30
+ */
31
+ export function usePersistedState<S>(
32
+ key: string,
33
+ initial: S,
34
+ reconcilier?: (lu: unknown, initial: S) => S | undefined,
35
+ ): [S, Dispatch<SetStateAction<S>>, () => void] {
36
+ const [etat, setEtat] = useState<S>(initial)
37
+ const [lu, setLu] = useState(false)
38
+
39
+ // Les dernières valeurs, sans en faire des dépendances : `initial` est
40
+ // souvent un littéral recréé à chaque rendu, et relire le stockage à
41
+ // chaque rendu effacerait les changements en cours.
42
+ const initialRef = useRef(initial)
43
+ initialRef.current = initial
44
+ const reconcilierRef = useRef(reconcilier)
45
+ reconcilierRef.current = reconcilier
46
+
47
+ // Ce que le stockage contient DÉJÀ, sérialisé. On n'écrit que ce qui en
48
+ // diffère : sans cette garde, la lecture elle-même provoquerait une
49
+ // écriture, et « Réinitialiser » réécrirait aussitôt la vue par défaut
50
+ // dans la clé qu'il vient de vider.
51
+ const enregistre = useRef<string | null>(null)
52
+
53
+ useEffect(() => {
54
+ let valeur: S | undefined
55
+ try {
56
+ const brut = window.localStorage.getItem(key)
57
+ if (brut != null) {
58
+ const parse: unknown = JSON.parse(brut)
59
+ valeur = reconcilierRef.current
60
+ ? reconcilierRef.current(parse, initialRef.current)
61
+ : (parse as S)
62
+ }
63
+ } catch {
64
+ valeur = undefined
65
+ }
66
+ const retenue = valeur === undefined ? initialRef.current : valeur
67
+ enregistre.current = serialiser(retenue)
68
+ setEtat(retenue)
69
+ setLu(true)
70
+ }, [key])
71
+
72
+ useEffect(() => {
73
+ // Avant la lecture, l'état est `initial` : l'écrire écraserait la vue
74
+ // enregistrée par la vue par défaut, au premier rendu de chaque page.
75
+ if (!lu) return
76
+ const texte = serialiser(etat)
77
+ if (texte === null || texte === enregistre.current) return
78
+ try {
79
+ window.localStorage.setItem(key, texte)
80
+ enregistre.current = texte
81
+ } catch {
82
+ // Stockage refusé : l'état vit en mémoire, c'est tout.
83
+ }
84
+ }, [key, etat, lu])
85
+
86
+ const reinitialiser = useCallback(() => {
87
+ try {
88
+ window.localStorage.removeItem(key)
89
+ } catch {
90
+ // Rien à vider qu'on puisse atteindre : on revient quand même au défaut.
91
+ }
92
+ enregistre.current = serialiser(initialRef.current)
93
+ setEtat(initialRef.current)
94
+ }, [key])
95
+
96
+ return [etat, setEtat, reinitialiser]
97
+ }
98
+
99
+ function serialiser(valeur: unknown): string | null {
100
+ try {
101
+ return JSON.stringify(valeur)
102
+ } catch {
103
+ return null
104
+ }
105
+ }
package/src/data.css CHANGED
@@ -980,3 +980,384 @@
980
980
  padding: 12px;
981
981
  }
982
982
  }
983
+
984
+ /* ─── Grille de données (DataGrid) ─────────────────────────────────────
985
+ * ⚠️ HORS RELEVÉ, tout ce bloc (24/10/2026) : aucune planche ne dessine un
986
+ * tableau qu'on réordonne, élargit, épingle ou coche. L'arbitrage et ses
987
+ * raisons sont dans DECISIONS.md, « La grille de données ». Aucune valeur
988
+ * nouvelle n'entre : les gouttières sont celles du tableau (14 et 10 px),
989
+ * les fonds ceux du paquet (blanc, --panel au survol, --accent-soft pour
990
+ * le choisi), les filets --line et --hairline.
991
+ *
992
+ * La grille est un MODE de .zv-table, pas un autre composant : mêmes
993
+ * lignes, mêmes cellules, même passage en cartes sous 768 px. */
994
+
995
+ .zv-grille {
996
+ display: grid;
997
+ gap: 12px;
998
+ min-width: 0;
999
+ }
1000
+
1001
+ /* Recherche à gauche, actions à droite ; elles passent à la ligne plutôt
1002
+ * que de pousser le cadre sur un écran étroit. */
1003
+ .zv-grille__outils {
1004
+ display: flex;
1005
+ flex-wrap: wrap;
1006
+ align-items: center;
1007
+ justify-content: space-between;
1008
+ gap: 10px;
1009
+ }
1010
+
1011
+ .zv-grille__outils > .zv-search {
1012
+ flex: 1 1 240px;
1013
+ max-width: 360px;
1014
+ }
1015
+
1016
+ .zv-grille__actions {
1017
+ display: flex;
1018
+ flex-wrap: wrap;
1019
+ align-items: center;
1020
+ gap: 8px;
1021
+ }
1022
+
1023
+ /* Les gouttières de la rangée deviennent des PARAMÈTRES : les colonnes
1024
+ * épinglées en ont besoin pour calculer leur décalage, et la largeur
1025
+ * minimale des rangées pour additionner ses pistes. Mêmes valeurs que
1026
+ * .zv-row et .zv-table__head (dérivées, pas relevées : redites ici). */
1027
+ .zv-table--grille {
1028
+ --zv-grille-pad: 14px;
1029
+ --zv-grille-gap: 10px;
1030
+ /* Un seul ascenseur pour l'en-tête et les lignes : le cadre. */
1031
+ position: relative;
1032
+ }
1033
+
1034
+ /* Une rangée de grille est au moins aussi large que la somme de ses
1035
+ * pistes PLANCHERS : sans cela, des colonnes réglées en pixels déborderaient
1036
+ * de la rangée, et le filet comme le fond de survol s'arrêteraient au bord
1037
+ * du cadre pendant que les cellules continuent. --zv-cols-somme et
1038
+ * --zv-cols-n sont posés par le composant (largeurs choisies par
1039
+ * l'utilisateur, pas des valeurs de charte). */
1040
+ .zv-table--grille > .zv-table__head,
1041
+ .zv-table--grille > .zv-row {
1042
+ min-width: calc(
1043
+ var(--zv-cols-somme, 0px) + 2 * var(--zv-grille-pad) +
1044
+ (var(--zv-cols-n, 1) - 1) * var(--zv-grille-gap)
1045
+ );
1046
+ padding-inline: var(--zv-grille-pad);
1047
+ column-gap: var(--zv-grille-gap);
1048
+ }
1049
+
1050
+ /* Rangée de FILTRES, sous les intitulés : même fond, mais ni capitales ni
1051
+ * interlettrage, ce sont des champs. Le filet de l'en-tête passe sous elle. */
1052
+ .zv-table__head--filtres {
1053
+ padding-block: 6px;
1054
+ font-family: var(--font-text);
1055
+ font-weight: 400;
1056
+ letter-spacing: normal;
1057
+ text-transform: none;
1058
+ }
1059
+
1060
+ .zv-table__head:has(+ .zv-table__head--filtres) {
1061
+ border-bottom-color: var(--hairline);
1062
+ }
1063
+
1064
+ /* Le champ de filtre : le .zv-input du paquet, resserré à la hauteur d'une
1065
+ * rangée d'en-tête. Une seule classe suffit à l'emporter : data.css est
1066
+ * chargé après forms.css (index.css). */
1067
+ .zv-table__filtre {
1068
+ padding: 5px 8px;
1069
+ font-size: var(--app-sm);
1070
+ }
1071
+
1072
+ /* ─── En-tête de colonne de grille ──────────────────────────────────── */
1073
+
1074
+ .zv-colhead {
1075
+ position: relative;
1076
+ display: flex;
1077
+ align-items: center;
1078
+ gap: 4px;
1079
+ /* La poignée de largeur déborde dans la gouttière : sans cela elle
1080
+ * serait rognée par l'overflow de .zv-cell. */
1081
+ overflow: visible;
1082
+ }
1083
+
1084
+ /* L'intitulé tronque, pas la cellule : la poignée et la marque de tri
1085
+ * doivent rester visibles sur une colonne étroite. */
1086
+ .zv-colhead .zv-sort {
1087
+ min-width: 0;
1088
+ }
1089
+
1090
+ .zv-colhead__label {
1091
+ min-width: 0;
1092
+ overflow: hidden;
1093
+ text-overflow: ellipsis;
1094
+ }
1095
+
1096
+ /* Poignée de déplacement. Un <button> : c'est elle qui porte le clavier
1097
+ * (Espace pour saisir, flèches, Espace pour poser). `touch-action: none` :
1098
+ * sans lui, le doigt fait défiler le cadre au lieu de saisir la colonne. */
1099
+ .zv-colhead__grip {
1100
+ flex: none;
1101
+ display: inline-grid;
1102
+ place-items: center;
1103
+ margin: -2px 0 -2px -4px;
1104
+ padding: 2px;
1105
+ border: 0;
1106
+ background: none;
1107
+ color: var(--label);
1108
+ cursor: grab;
1109
+ touch-action: none;
1110
+ }
1111
+
1112
+ .zv-colhead__grip:hover {
1113
+ color: var(--accent);
1114
+ }
1115
+
1116
+ .zv-colhead__grip:focus-visible {
1117
+ outline-offset: -2px;
1118
+ }
1119
+
1120
+ /* Tout l'en-tête se saisit : la main ouverte le dit au survol. Le bouton
1121
+ * de tri garde son pointeur, puisqu'un clic simple trie toujours. */
1122
+ .zv-colhead--deplacable {
1123
+ cursor: grab;
1124
+ touch-action: none;
1125
+ }
1126
+
1127
+ /* La colonne qu'on déplace : le fond de l'entrée choisie, et elle passe
1128
+ * au-dessus de ses voisines qu'elle traverse. */
1129
+ .zv-colhead--drag {
1130
+ z-index: 3;
1131
+ background: var(--accent-soft);
1132
+ cursor: grabbing;
1133
+ }
1134
+
1135
+ /* Poignée de largeur : une bande de saisie de la largeur de la gouttière,
1136
+ * centrée sur la frontière, et un filet qui ne se montre qu'à l'approche. */
1137
+ .zv-colhead__resize {
1138
+ position: absolute;
1139
+ top: -9px;
1140
+ bottom: -9px;
1141
+ right: calc(-1 * var(--zv-grille-gap, 10px) / 2 - 4px);
1142
+ z-index: 2;
1143
+ width: 8px;
1144
+ cursor: col-resize;
1145
+ touch-action: none;
1146
+ }
1147
+
1148
+ .zv-colhead__resize::after {
1149
+ content: '';
1150
+ position: absolute;
1151
+ top: 9px;
1152
+ bottom: 9px;
1153
+ left: 3px;
1154
+ width: 2px;
1155
+ background: var(--line);
1156
+ opacity: 0;
1157
+ }
1158
+
1159
+ .zv-colhead:hover .zv-colhead__resize::after {
1160
+ opacity: 1;
1161
+ }
1162
+
1163
+ .zv-colhead__resize:hover::after,
1164
+ .zv-colhead__resize:focus-visible::after,
1165
+ .zv-colhead__resize--actif::after {
1166
+ background: var(--accent);
1167
+ opacity: 1;
1168
+ }
1169
+
1170
+ .zv-colhead__resize:focus-visible {
1171
+ outline: none;
1172
+ }
1173
+
1174
+ /* Pendant le geste, le curseur ne doit pas changer en traversant les
1175
+ * cellules voisines. */
1176
+ .zv-colhead__resize--actif {
1177
+ cursor: col-resize;
1178
+ }
1179
+
1180
+ /* ─── Colonnes épinglées ────────────────────────────────────────────────
1181
+ * Collées au bord du cadre pendant que le reste défile. Le fond est OPAQUE
1182
+ * et suit le contexte (blanc, survol, choisi) par une variable : une cellule
1183
+ * transparente laisserait passer les valeurs qui glissent dessous. L'ombre
1184
+ * pleine, sans flou, bouche la gouttière : c'est une géométrie, pas une
1185
+ * ombre de charte. */
1186
+ .zv-cell--pin {
1187
+ --zv-pin-fond: var(--white);
1188
+ position: sticky;
1189
+ z-index: 1;
1190
+ background: var(--zv-pin-fond);
1191
+ box-shadow: calc(-1 * var(--zv-grille-gap, 10px)) 0 0 0 var(--zv-pin-fond);
1192
+ }
1193
+
1194
+ .zv-cell--pin-droite {
1195
+ box-shadow: var(--zv-grille-gap, 10px) 0 0 0 var(--zv-pin-fond);
1196
+ }
1197
+
1198
+ /* La frontière entre ce qui reste et ce qui défile : un filet --line,
1199
+ * côté contenu. */
1200
+ .zv-cell--pin-bord {
1201
+ box-shadow:
1202
+ calc(-1 * var(--zv-grille-gap, 10px)) 0 0 0 var(--zv-pin-fond),
1203
+ calc(var(--zv-grille-gap, 10px) / 2) 0 0 0 var(--zv-pin-fond),
1204
+ calc(var(--zv-grille-gap, 10px) / 2 + 1px) 0 0 0 var(--line);
1205
+ }
1206
+
1207
+ .zv-cell--pin-droite.zv-cell--pin-bord {
1208
+ box-shadow:
1209
+ var(--zv-grille-gap, 10px) 0 0 0 var(--zv-pin-fond),
1210
+ calc(-1 * var(--zv-grille-gap, 10px) / 2) 0 0 0 var(--zv-pin-fond),
1211
+ calc(-1 * var(--zv-grille-gap, 10px) / 2 - 1px) 0 0 0 var(--line);
1212
+ }
1213
+
1214
+ .zv-table__head .zv-cell--pin,
1215
+ .zv-row:hover .zv-cell--pin {
1216
+ --zv-pin-fond: var(--panel);
1217
+ }
1218
+
1219
+ /* ─── Ligne choisie ─────────────────────────────────────────────────────
1220
+ * Le fond de l'entrée de menu choisie. Il tient au survol : sinon, la
1221
+ * ligne qu'on vient de cocher perdrait sa marque sous le pointeur. */
1222
+ .zv-row--selected,
1223
+ .zv-row--selected:hover {
1224
+ background: var(--accent-soft);
1225
+ }
1226
+
1227
+ .zv-row--selected .zv-cell--pin,
1228
+ .zv-row--selected:hover .zv-cell--pin {
1229
+ --zv-pin-fond: var(--accent-soft);
1230
+ }
1231
+
1232
+ /* La case de sélection : centrée dans sa piste, sans troncature. */
1233
+ .zv-cell--case {
1234
+ display: flex;
1235
+ align-items: center;
1236
+ overflow: visible;
1237
+ }
1238
+
1239
+ /* Un nombre s'aligne à droite : son intitulé aussi. */
1240
+ .zv-colhead.zv-cell--num {
1241
+ justify-content: flex-end;
1242
+ }
1243
+
1244
+ /* Le menu des colonnes s'ouvre depuis la DROITE de la barre : ancré à
1245
+ * gauche comme le menu de sélection, il sortirait de l'écran. Il défile
1246
+ * au-delà d'une douzaine de colonnes plutôt que de couvrir la page. */
1247
+ .zv-grille__menu {
1248
+ left: auto;
1249
+ right: 0;
1250
+ max-height: min(420px, 70vh);
1251
+ overflow-y: auto;
1252
+ }
1253
+
1254
+ .zv-grille__menu-ligne {
1255
+ display: flex;
1256
+ align-items: center;
1257
+ gap: 4px;
1258
+ }
1259
+
1260
+ /* L'entrée de menu porte ici une case et un intitulé : elle redevient une
1261
+ * rangée, et la case ne s'écrase pas. */
1262
+ .zv-grille__menu-case {
1263
+ display: flex;
1264
+ flex: 1;
1265
+ align-items: center;
1266
+ gap: 8px;
1267
+ min-width: 0;
1268
+ }
1269
+
1270
+ /* L'épingle : un bouton d'icône à la hauteur d'une entrée de menu, pas à
1271
+ * celle d'une cible de barre (--tap). */
1272
+ .zv-grille__epingle {
1273
+ flex: none;
1274
+ width: 30px;
1275
+ height: 30px;
1276
+ }
1277
+
1278
+ .zv-grille__epingle[aria-pressed='true'] {
1279
+ color: var(--accent);
1280
+ }
1281
+
1282
+ /* La taille de page : le <select> du paquet, à sa largeur de contenu. */
1283
+ .zv-grille__taille {
1284
+ width: auto;
1285
+ padding-block: 5px;
1286
+ font-size: var(--app-sm);
1287
+ }
1288
+
1289
+ /* Le résumé d'une plage (nombres, dates) dans la rangée de filtres : un
1290
+ * déclencheur à l'apparence du champ, resserré comme lui. */
1291
+ .zv-table__filtre.zv-input--trigger {
1292
+ gap: 4px;
1293
+ min-width: 0;
1294
+ width: 100%;
1295
+ }
1296
+
1297
+ /* L'enveloppe du popover de plage, rendue HORS du tableau (portail) : le
1298
+ * cadre défile, il rognerait le panneau. Placée en coordonnées d'écran par
1299
+ * le composant ; le panneau, .zv-popover, s'y pose comme sous son ancre. */
1300
+ .zv-grille__flottant {
1301
+ position: fixed;
1302
+ z-index: 60;
1303
+ }
1304
+
1305
+ /* Raccourcis, bornes, effacement : un petit formulaire. Le popover du
1306
+ * paquet borne sa largeur à 320 px, trop juste pour deux champs de date
1307
+ * côte à côte : la grille la reprend (même spécificité, data.css est chargé
1308
+ * après navigation.css). */
1309
+ .zv-grille__plage {
1310
+ display: grid;
1311
+ gap: 14px;
1312
+ width: 340px;
1313
+ max-width: calc(100vw - 16px);
1314
+ }
1315
+
1316
+ /* Les raccourcis de période : des entrées de menu (le survol et l'état
1317
+ * choisi du paquet), sur deux colonnes. Le filet les sépare des bornes. */
1318
+ .zv-grille__periodes {
1319
+ display: grid;
1320
+ grid-template-columns: 1fr 1fr;
1321
+ gap: 2px;
1322
+ margin: -6px -8px 0;
1323
+ padding-bottom: 12px;
1324
+ border-bottom: 1px solid var(--hairline);
1325
+ }
1326
+
1327
+ .zv-grille__bornes {
1328
+ display: grid;
1329
+ grid-template-columns: 1fr 1fr;
1330
+ gap: 10px;
1331
+ }
1332
+
1333
+ /* Un champ date natif a une largeur intrinsèque : sans min-width, il
1334
+ * pousse sa colonne hors du popover. */
1335
+ .zv-grille__bornes .zv-input {
1336
+ min-width: 0;
1337
+ padding-inline: 8px;
1338
+ }
1339
+
1340
+ /* Le pied : compte à gauche, taille de page et sens à droite. */
1341
+ .zv-grille__pied {
1342
+ display: flex;
1343
+ align-items: center;
1344
+ gap: 14px;
1345
+ }
1346
+
1347
+ @media (max-width: 767px) {
1348
+ /* En cartes, l'épinglage, les poignées et la largeur minimale n'ont plus
1349
+ * de sens : la carte prend la largeur de l'écran. */
1350
+ .zv-table--grille > .zv-row {
1351
+ min-width: 0;
1352
+ }
1353
+
1354
+ .zv-cell--pin {
1355
+ position: static;
1356
+ box-shadow: none;
1357
+ background: none;
1358
+ }
1359
+
1360
+ .zv-table__head--filtres {
1361
+ display: none;
1362
+ }
1363
+ }
package/src/forms.css CHANGED
@@ -514,6 +514,20 @@
514
514
  background-size: 12px 12px;
515
515
  }
516
516
 
517
+ /* Case INDÉTERMINÉE (« une partie des lignes est cochée »), en tête d'une
518
+ * colonne de sélection. ⚠️ HORS RELEVÉ (24/10/2026, grille de données) :
519
+ * aucune planche ne la dessine. Même facture que la coche : aplat accent,
520
+ * trait blanc carré de 1,5. L'état vient de la propriété DOM
521
+ * `indeterminate`, que seul le script pose : il n'a pas d'attribut HTML. */
522
+ .zv-check-box:indeterminate {
523
+ border-color: var(--accent);
524
+ background-color: var(--accent);
525
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23fff' stroke-width='1.5' stroke-linecap='square'%3E%3Cline x1='3' y1='6' x2='9' y2='6'/%3E%3C/svg%3E");
526
+ background-position: center;
527
+ background-repeat: no-repeat;
528
+ background-size: 12px 12px;
529
+ }
530
+
517
531
  /* Le point du radio est creusé dans l'aplat par un anneau blanc interne :
518
532
  * un pseudo-élément sur un <input> n'est pas fiable d'un moteur à l'autre.
519
533
  * Ce box-shadow est une géométrie, pas une ombre de charte : d'où l'absence