@zevra/ui 0.38.0 → 0.39.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.
@@ -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
@@ -209,6 +209,84 @@ export function Modal({
209
209
  )
210
210
  }
211
211
 
212
+ /* ─── Volet ────────────────────────────────────────────────────────── */
213
+
214
+ export interface VoletProps extends Omit<Div, 'title'> {
215
+ ouvert: boolean
216
+ onClose: () => void
217
+ titre?: ReactNode
218
+ /** Nom du dialogue quand le titre n'est pas une chaîne. */
219
+ label?: string
220
+ /** Actions de tête, à droite du titre (la croix, en général). */
221
+ actions?: ReactNode
222
+ /** Pied d'actions, fixe quand le corps défile. */
223
+ pied?: ReactNode
224
+ /** `normale` pour une fiche, `large` pour un document à lire. */
225
+ largeur?: 'normale' | 'large'
226
+ /** Vrai par défaut : dialogue modal, page voilée et figée. Faux : la page
227
+ * reste utilisable derrière (passer d'une pièce à l'autre d'une liste sans
228
+ * refermer). Échap ferme dans les deux cas. */
229
+ voile?: boolean
230
+ /** `nu` : corps sans marge, pour un document bord à bord (PDF, image). */
231
+ corps?: 'normal' | 'nu'
232
+ className?: string
233
+ children?: ReactNode
234
+ }
235
+
236
+ /** Panneau latéral DROIT, à toutes les largeurs : un contenu qu'on consulte
237
+ * à côté d'une liste (un document, une fiche). Ne remplace ni le tiroir,
238
+ * qui est la navigation mobile, ni la modale, qui interrompt pour une
239
+ * décision. Reste monté fermé, comme le tiroir, pour glisser dehors. */
240
+ export function Volet({
241
+ ouvert,
242
+ onClose,
243
+ titre,
244
+ label,
245
+ actions,
246
+ pied,
247
+ largeur = 'normale',
248
+ voile = true,
249
+ corps = 'normal',
250
+ className,
251
+ children,
252
+ ...reste
253
+ }: VoletProps) {
254
+ const panneau = useRef<HTMLDivElement>(null)
255
+ // Sans voile, la page reste à l'utilisateur : ni verrou de défilement, ni
256
+ // piège de focus (Tab doit pouvoir revenir à la liste). Échap ferme.
257
+ useOverlay({ ouvert, onClose, panneau: voile ? panneau : undefined, verrouille: voile })
258
+ const fermer = useCallback(() => onClose(), [onClose])
259
+
260
+ return (
261
+ <div
262
+ role="dialog"
263
+ aria-modal={voile ? 'true' : undefined}
264
+ aria-label={label ?? (typeof titre === 'string' ? titre : undefined)}
265
+ aria-hidden={!ouvert}
266
+ className={cx(
267
+ 'zv-volet',
268
+ ouvert && 'zv-volet--open',
269
+ largeur === 'large' && 'zv-volet--large',
270
+ !voile && 'zv-volet--sans-voile',
271
+ className,
272
+ )}
273
+ {...reste}
274
+ >
275
+ {voile && <div className="zv-scrim" onClick={fermer} aria-hidden="true" />}
276
+ <div className="zv-volet__panel" ref={panneau}>
277
+ {(titre || actions) && (
278
+ <div className="zv-volet__head">
279
+ {titre && <h2 className="zv-volet__title">{titre}</h2>}
280
+ {actions && <div className="zv-volet__actions">{actions}</div>}
281
+ </div>
282
+ )}
283
+ <div className={cx('zv-volet__body', corps === 'nu' && 'zv-volet__body--nu')}>{children}</div>
284
+ {pied && <div className="zv-volet__foot">{pied}</div>}
285
+ </div>
286
+ </div>
287
+ )
288
+ }
289
+
212
290
  /* ─── Notification flottante ───────────────────────────────────────── */
213
291
 
214
292
  /** 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 {