@zevra/ui 0.38.1 → 0.39.1

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.
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- // Enrobages : voile, tiroir, modale, notification flottante.
3
+ // Enrobages : voile, tiroir, volet, modale, notification flottante.
4
4
  //
5
5
  // SEUL fichier du paquet marqué « use client », avec un hook. Tout ce qui vit
6
6
  // ici vole le regard à la page : il faut une touche Échap, un piège de focus
@@ -40,6 +40,19 @@ export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseO
40
40
  // utilisateur au clavier perd sa place dans la page.
41
41
  const origine = useRef<HTMLElement | null>(null)
42
42
 
43
+ // ⚠️ `onClose` NE FIGURE PAS dans les dépendances de l'effet, et ce n'est pas
44
+ // un oubli. L'effet ne se contente pas d'écouter Échap : il mémorise le
45
+ // nœud focalisé, verrouille le défilement et donne le focus au premier
46
+ // élément du panneau. Le rejouer, ce n'est donc pas re-souscrire, c'est
47
+ // ROUVRIR l'enrobage. Or `onClose` est presque toujours une fonction
48
+ // anonyme, recréée à chaque rendu du parent : le focus repartait sur le
49
+ // premier bouton du panneau à chaque rendu de la page derrière, et un champ
50
+ // qui s'enregistre tout seul devenait impossible à remplir (la note d'une
51
+ // pièce dans Tésope, 30/09/2026). L'effet lit ici la dernière fonction
52
+ // connue ; ses dépendances ne portent que l'état.
53
+ const fermer = useRef(onClose)
54
+ fermer.current = onClose
55
+
43
56
  useEffect(() => {
44
57
  if (!ouvert) return
45
58
  const cible = panneau?.current
@@ -62,7 +75,7 @@ export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseO
62
75
  function onKey(e: KeyboardEvent) {
63
76
  if (e.key === 'Escape') {
64
77
  e.stopPropagation()
65
- onClose()
78
+ fermer.current()
66
79
  return
67
80
  }
68
81
  if (e.key !== 'Tab' || !cible) return
@@ -89,7 +102,7 @@ export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseO
89
102
  if (overflowPrecedent !== null) docu.body.style.overflow = overflowPrecedent
90
103
  origine.current?.focus?.()
91
104
  }
92
- }, [ouvert, onClose, panneau, verrouille])
105
+ }, [ouvert, panneau, verrouille])
93
106
  }
94
107
 
95
108
  /* ─── Voile ────────────────────────────────────────────────────────── */
@@ -209,6 +222,84 @@ export function Modal({
209
222
  )
210
223
  }
211
224
 
225
+ /* ─── Volet ────────────────────────────────────────────────────────── */
226
+
227
+ export interface VoletProps extends Omit<Div, 'title'> {
228
+ ouvert: boolean
229
+ onClose: () => void
230
+ titre?: ReactNode
231
+ /** Nom du dialogue quand le titre n'est pas une chaîne. */
232
+ label?: string
233
+ /** Actions de tête, à droite du titre (la croix, en général). */
234
+ actions?: ReactNode
235
+ /** Pied d'actions, fixe quand le corps défile. */
236
+ pied?: ReactNode
237
+ /** `normale` pour une fiche, `large` pour un document à lire. */
238
+ largeur?: 'normale' | 'large'
239
+ /** Vrai par défaut : dialogue modal, page voilée et figée. Faux : la page
240
+ * reste utilisable derrière (passer d'une pièce à l'autre d'une liste sans
241
+ * refermer). Échap ferme dans les deux cas. */
242
+ voile?: boolean
243
+ /** `nu` : corps sans marge, pour un document bord à bord (PDF, image). */
244
+ corps?: 'normal' | 'nu'
245
+ className?: string
246
+ children?: ReactNode
247
+ }
248
+
249
+ /** Panneau latéral DROIT, à toutes les largeurs : un contenu qu'on consulte
250
+ * à côté d'une liste (un document, une fiche). Ne remplace ni le tiroir,
251
+ * qui est la navigation mobile, ni la modale, qui interrompt pour une
252
+ * décision. Reste monté fermé, comme le tiroir, pour glisser dehors. */
253
+ export function Volet({
254
+ ouvert,
255
+ onClose,
256
+ titre,
257
+ label,
258
+ actions,
259
+ pied,
260
+ largeur = 'normale',
261
+ voile = true,
262
+ corps = 'normal',
263
+ className,
264
+ children,
265
+ ...reste
266
+ }: VoletProps) {
267
+ const panneau = useRef<HTMLDivElement>(null)
268
+ // Sans voile, la page reste à l'utilisateur : ni verrou de défilement, ni
269
+ // piège de focus (Tab doit pouvoir revenir à la liste). Échap ferme.
270
+ useOverlay({ ouvert, onClose, panneau: voile ? panneau : undefined, verrouille: voile })
271
+ const fermer = useCallback(() => onClose(), [onClose])
272
+
273
+ return (
274
+ <div
275
+ role="dialog"
276
+ aria-modal={voile ? 'true' : undefined}
277
+ aria-label={label ?? (typeof titre === 'string' ? titre : undefined)}
278
+ aria-hidden={!ouvert}
279
+ className={cx(
280
+ 'zv-volet',
281
+ ouvert && 'zv-volet--open',
282
+ largeur === 'large' && 'zv-volet--large',
283
+ !voile && 'zv-volet--sans-voile',
284
+ className,
285
+ )}
286
+ {...reste}
287
+ >
288
+ {voile && <div className="zv-scrim" onClick={fermer} aria-hidden="true" />}
289
+ <div className="zv-volet__panel" ref={panneau}>
290
+ {(titre || actions) && (
291
+ <div className="zv-volet__head">
292
+ {titre && <h2 className="zv-volet__title">{titre}</h2>}
293
+ {actions && <div className="zv-volet__actions">{actions}</div>}
294
+ </div>
295
+ )}
296
+ <div className={cx('zv-volet__body', corps === 'nu' && 'zv-volet__body--nu')}>{children}</div>
297
+ {pied && <div className="zv-volet__foot">{pied}</div>}
298
+ </div>
299
+ </div>
300
+ )
301
+ }
302
+
212
303
  /* ─── Notification flottante ───────────────────────────────────────── */
213
304
 
214
305
  /** Le conteneur ne capte RIEN : seules les notifications le font, sans quoi
package/src/overlays.css CHANGED
@@ -12,7 +12,8 @@
12
12
  * l'échappement au clavier est du ressort du gabarit, que le CSS ne peut pas
13
13
  * assurer seul.
14
14
  *
15
- * Empilement, dans l'ordre : voile 60, tiroir 80, modale 90, notification 100.
15
+ * Empilement, dans l'ordre : voile 60, tiroir 80, volet 85, modale 90,
16
+ * notification 100.
16
17
  * Les couches inférieures (2, 5, 40, 50) sont dans layout.css.
17
18
  *
18
19
  * OUVERTURE, deux écritures équivalentes, au choix du gabarit :
@@ -51,7 +52,8 @@
51
52
  html:has(.zv-drawer[data-open]),
52
53
  html:has(.zv-drawer--open),
53
54
  html:has(.zv-modal[data-open]),
54
- html:has(.zv-modal--open) {
55
+ html:has(.zv-modal--open),
56
+ html:has(.zv-volet--open:not(.zv-volet--sans-voile)) {
55
57
  overflow: hidden;
56
58
  }
57
59
 
@@ -255,6 +257,135 @@ html:has(.zv-modal--open) {
255
257
  border-top: 3px solid var(--err);
256
258
  }
257
259
 
260
+ /* ─── Volet ─────────────────────────────────────────────────────────────
261
+ * ⚠️ HORS RELEVÉ (DECISIONS.md, 26/09/2026). Panneau latéral DROIT, à toutes
262
+ * les largeurs : il porte un contenu (un document, une fiche) qu'on consulte
263
+ * à côté d'une liste. Dérivé du tiroir (le glissement, le voile) et de la
264
+ * modale (tête, corps qui défile, pied fixe) ; seules ses deux largeurs sont
265
+ * nouvelles.
266
+ *
267
+ * Empilement 85 : devant le tiroir (80), derrière la modale (90), pour
268
+ * qu'une confirmation ouverte depuis le volet passe devant lui.
269
+ *
270
+ * Sans voile (`zv-volet--sans-voile`), le conteneur ne capte rien : seul le
271
+ * panneau reçoit les clics, et la page reste utilisable derrière. */
272
+
273
+ .zv-volet {
274
+ --zv-volet-largeur: min(560px, 92vw);
275
+ position: fixed;
276
+ inset: 0;
277
+ z-index: 85;
278
+ /* `visibility` plutôt que `display`, comme le tiroir : le panneau reste
279
+ * à l'écran le temps qu'il glisse dehors. */
280
+ visibility: hidden;
281
+ transition: visibility 200ms cubic-bezier(0.4, 0, 0.2, 1);
282
+ }
283
+
284
+ .zv-volet--large {
285
+ --zv-volet-largeur: min(960px, 96vw);
286
+ }
287
+
288
+ .zv-volet[data-open],
289
+ .zv-volet--open {
290
+ visibility: visible;
291
+ }
292
+
293
+ .zv-volet--sans-voile {
294
+ pointer-events: none;
295
+ }
296
+
297
+ /* Le voile du volet existe à toutes les largeurs, comme celui de la modale. */
298
+ .zv-volet .zv-scrim {
299
+ z-index: 0;
300
+ display: block;
301
+ opacity: 0;
302
+ transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1);
303
+ }
304
+
305
+ .zv-volet[data-open] .zv-scrim,
306
+ .zv-volet--open .zv-scrim {
307
+ opacity: 1;
308
+ }
309
+
310
+ .zv-volet__panel {
311
+ pointer-events: auto;
312
+ position: absolute;
313
+ z-index: 1;
314
+ inset-block: 0;
315
+ right: 0;
316
+ display: flex;
317
+ width: var(--zv-volet-largeur);
318
+ flex-direction: column;
319
+ border-left: 1px solid var(--line);
320
+ border-radius: 0;
321
+ background: var(--white);
322
+ box-shadow: var(--shadow-panel);
323
+ transform: translateX(100%);
324
+ transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
325
+ }
326
+
327
+ .zv-volet[data-open] .zv-volet__panel,
328
+ .zv-volet--open .zv-volet__panel {
329
+ transform: translateX(0);
330
+ }
331
+
332
+ .zv-volet__head {
333
+ display: flex;
334
+ align-items: center;
335
+ justify-content: space-between;
336
+ gap: 12px;
337
+ border-bottom: 1px solid var(--hairline);
338
+ padding: 14px 18px;
339
+ }
340
+
341
+ .zv-volet__title {
342
+ min-width: 0;
343
+ margin: 0;
344
+ overflow: hidden;
345
+ text-overflow: ellipsis;
346
+ white-space: nowrap;
347
+ font-family: var(--font-display);
348
+ letter-spacing: -0.02em;
349
+ font-size: var(--app-h2);
350
+ font-weight: 400;
351
+ color: var(--ink);
352
+ }
353
+
354
+ .zv-volet__actions {
355
+ display: flex;
356
+ flex-shrink: 0;
357
+ align-items: center;
358
+ gap: 6px;
359
+ }
360
+
361
+ /* Le corps seul défile : tête et pied restent, les actions ne sortent
362
+ * jamais de l'écran. */
363
+ .zv-volet__body {
364
+ display: flex;
365
+ min-height: 0;
366
+ flex: 1;
367
+ flex-direction: column;
368
+ overflow: auto;
369
+ padding: 14px 18px;
370
+ font-size: var(--app);
371
+ line-height: 1.6;
372
+ color: var(--muted);
373
+ }
374
+
375
+ /* Un document qui doit aller bord à bord (PDF, image) : pas de marge. */
376
+ .zv-volet__body--nu {
377
+ padding: 0;
378
+ }
379
+
380
+ .zv-volet__foot {
381
+ display: flex;
382
+ justify-content: flex-end;
383
+ gap: 10px;
384
+ border-top: 1px solid var(--hairline);
385
+ background: var(--white);
386
+ padding: 12px 18px;
387
+ }
388
+
258
389
  /* ─── Notification flottante ────────────────────────────────────────────
259
390
  * ⚠️ HORS RELEVÉ, même raison : Mémoire signale tout en place, par une
260
391
  * alerte de bloc. Cette notification est l'alerte du module de retours,
@@ -318,6 +449,13 @@ html:has(.zv-modal--open) {
318
449
  display: block;
319
450
  }
320
451
 
452
+ /* Sous le seuil, le volet prend toute la largeur : une bande de page de
453
+ * 30 px à gauche ne sert à rien, le voile se ferme par Échap ou la croix. */
454
+ .zv-volet,
455
+ .zv-volet--large {
456
+ --zv-volet-largeur: 100vw;
457
+ }
458
+
321
459
  /* La notification prend la largeur : un coin de 380px sur un écran de 390
322
460
  * n'est plus un coin. */
323
461
  .zv-toasts {