@zevra/ui 0.14.0 → 0.15.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.
package/react/layout.tsx CHANGED
@@ -4,10 +4,18 @@
4
4
  // Couche VITRINE : une application n'a aucune raison de charger ce fichier.
5
5
  // Tout y est sans état.
6
6
 
7
- import type { ElementType, HTMLAttributes, ReactNode } from 'react'
7
+ import type {
8
+ ButtonHTMLAttributes,
9
+ CSSProperties,
10
+ ElementType,
11
+ HTMLAttributes,
12
+ ReactNode,
13
+ } from 'react'
8
14
  import { cx } from './cx.js'
9
15
 
10
16
  type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
17
+ type Span = Omit<HTMLAttributes<HTMLSpanElement>, 'className'>
18
+ type Btn = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'>
11
19
 
12
20
  /* ─── Page et gouttières ───────────────────────────────────────────── */
13
21
 
@@ -206,6 +214,146 @@ export function NavDrawerLink({
206
214
  )
207
215
  }
208
216
 
217
+ export function NavDrawerGroup({ className, ...reste }: Span & { className?: string }) {
218
+ return (
219
+ <span className={cx('zv-mono zv-mono--label zv-nav-drawer__groupe', className)} {...reste} />
220
+ )
221
+ }
222
+
223
+ /* ─── Entrées à menu déroulant ─────────────────────────────────────── */
224
+
225
+ /** Une famille qui se déplie. Ouverture au survol ET au focus, en CSS seul :
226
+ * pas d'état à tenir, donc rien à hydrater — une barre de vitrine reste
227
+ * rendue au serveur. */
228
+ export function NavDropdown({ className, ...reste }: Span & { className?: string }) {
229
+ return <span className={cx('zv-navdd', className)} {...reste} />
230
+ }
231
+
232
+ /** Le déclencheur. Il porte `.zv-navlink` pour se rendre comme un lien simple :
233
+ * ne JAMAIS lui poser `font: inherit`, qui reprendrait la fonte du conteneur
234
+ * et le grossirait par rapport à ses voisins. */
235
+ export function NavDropdownButton({
236
+ className,
237
+ type = 'button',
238
+ children,
239
+ ...reste
240
+ }: Btn & { className?: string; type?: 'button'; children?: ReactNode }) {
241
+ return (
242
+ <button
243
+ type={type}
244
+ aria-haspopup="true"
245
+ className={cx('zv-navlink zv-navdd__btn', className)}
246
+ {...reste}
247
+ >
248
+ {children}
249
+ <svg className="zv-navdd__chevron" viewBox="0 0 24 24" aria-hidden="true">
250
+ <path d="M6 9.4 L12 15.4 L18 9.4" />
251
+ </svg>
252
+ </button>
253
+ )
254
+ }
255
+
256
+ /** Le panneau : c'est le `.zv-menu` du paquet, ancré sous l'entrée. */
257
+ export function NavDropdownMenu({ className, ...reste }: Span & { className?: string }) {
258
+ return <span className={cx('zv-menu zv-navdd__menu', className)} {...reste} />
259
+ }
260
+
261
+ export function NavDropdownSep({ className, ...reste }: Span & { className?: string }) {
262
+ return <span aria-hidden="true" className={cx('zv-navdd__sep', className)} {...reste} />
263
+ }
264
+
265
+ /* ─── Sélecteur de langue ──────────────────────────────────────────── */
266
+
267
+ /** ⚠️ Ne pas le poser sur une page qui n'existe que dans une langue : mieux
268
+ * vaut pas de sélecteur qu'un sélecteur qui mène à des 404. */
269
+ export function Langues({
270
+ tiroir,
271
+ className,
272
+ ...reste
273
+ }: Span & { tiroir?: boolean; className?: string }) {
274
+ return (
275
+ <span
276
+ className={cx(
277
+ 'zv-langues zv-mono zv-mono--micro',
278
+ tiroir && 'zv-langues--tiroir',
279
+ className,
280
+ )}
281
+ {...reste}
282
+ />
283
+ )
284
+ }
285
+
286
+ export function Langue({
287
+ courante,
288
+ className,
289
+ ...reste
290
+ }: Omit<HTMLAttributes<HTMLAnchorElement>, 'className'> & {
291
+ courante?: boolean
292
+ href?: string
293
+ className?: string
294
+ }) {
295
+ return (
296
+ <a
297
+ aria-current={courante ? 'true' : undefined}
298
+ className={cx('zv-langue', courante && 'zv-langue--on', className)}
299
+ {...reste}
300
+ />
301
+ )
302
+ }
303
+
304
+ /* ─── Lockup ───────────────────────────────────────────────────────── */
305
+
306
+ export interface LockupProps extends Omit<Span, 'style'> {
307
+ style?: CSSProperties
308
+ /** Hauteur de CAPITALE, en px. Tout le reste s'en déduit. Sous 11, poser
309
+ * `marqueSeule` : le mot ne se lit plus, quelle que soit sa netteté. */
310
+ capitale?: number
311
+ marqueSeule?: boolean
312
+ className?: string
313
+ children?: ReactNode
314
+ }
315
+
316
+ /** La marque et le mot, aux proportions relevées sur le lockup d'origine. */
317
+ export function Lockup({
318
+ capitale = 15,
319
+ marqueSeule,
320
+ className,
321
+ style,
322
+ ...reste
323
+ }: LockupProps) {
324
+ return (
325
+ <span
326
+ className={cx('zv-lockup', marqueSeule && 'zv-lockup--marque-seule', className)}
327
+ style={{ ['--zv-cap' as string]: `${capitale}px`, ...style }}
328
+ {...reste}
329
+ />
330
+ )
331
+ }
332
+
333
+ export function LockupMark({
334
+ className,
335
+ alt = '',
336
+ ...reste
337
+ }: Omit<HTMLAttributes<HTMLImageElement>, 'className'> & {
338
+ className?: string
339
+ src: string
340
+ alt?: string
341
+ }) {
342
+ return <img alt={alt} className={cx('zv-lockup__mark', className)} {...reste} />
343
+ }
344
+
345
+ export function LockupWord({
346
+ className,
347
+ alt = '',
348
+ ...reste
349
+ }: Omit<HTMLAttributes<HTMLImageElement>, 'className'> & {
350
+ className?: string
351
+ src: string
352
+ alt?: string
353
+ }) {
354
+ return <img alt={alt} className={cx('zv-lockup__word', className)} {...reste} />
355
+ }
356
+
209
357
  /* ─── Frise d'étapes ───────────────────────────────────────────────── */
210
358
 
211
359
  export interface StepsProps extends Div {
package/src/layout.css CHANGED
@@ -313,8 +313,77 @@
313
313
  color: var(--label);
314
314
  }
315
315
 
316
- /* Header fixe du prototype : voile dégradé plutôt qu'un fond plein, pour
317
- * que le contenu s'y fonde au défilement. */
316
+ /* ─── Sélecteur de langue ───────────────────────────────────────────────
317
+ * Des initiales au registre mono, pas un menu : trois langues se lisent
318
+ * d'un coup d'œil, et un menu pour trois entrées coûte un clic pour rien.
319
+ * Une page qui n'existe que dans une langue ne le pose pas — il vaut mieux
320
+ * pas de sélecteur qu'un sélecteur qui mène à des 404. */
321
+
322
+ .zv-langues {
323
+ display: inline-flex;
324
+ align-items: center;
325
+ gap: 10px;
326
+ }
327
+
328
+ .zv-langue {
329
+ color: var(--label);
330
+ text-decoration: none;
331
+ letter-spacing: 0.12em;
332
+ transition: color 160ms var(--zv-easing, cubic-bezier(0.2, 0.7, 0.2, 1));
333
+ }
334
+
335
+ .zv-langue:hover {
336
+ color: var(--accent);
337
+ }
338
+
339
+ .zv-langue--on {
340
+ color: var(--ink);
341
+ font-weight: 700;
342
+ }
343
+
344
+ /* Dans le tiroir, il se détache en pied de liste. */
345
+ .zv-langues--tiroir {
346
+ display: flex;
347
+ gap: 16px;
348
+ margin-top: 18px;
349
+ padding-top: 14px;
350
+ border-top: 1px solid var(--hairline);
351
+ }
352
+
353
+ /* ─── Le tiroir, groupé ─────────────────────────────────────────────────
354
+ * Quand la barre porte des familles, les aplatir en une liste unique rend
355
+ * le tiroir illisible. Un label capitale par famille, ses entrées en
356
+ * dessous : la même architecture, repliée. */
357
+
358
+ .zv-nav-drawer__groupe {
359
+ display: block;
360
+ margin: 16px 0 4px;
361
+ }
362
+
363
+ .zv-nav-drawer__groupe:first-child {
364
+ margin-top: 0;
365
+ }
366
+
367
+ .zv-nav-drawer__link--sous {
368
+ font-size: 15px;
369
+ padding-block: 7px;
370
+ }
371
+
372
+ /* Chrome fixe : voile dégradé plutôt qu'un fond plein, pour que le contenu
373
+ * s'y fonde au défilement.
374
+ *
375
+ * Deux usages, une seule classe. Il est LA BARRE quand il porte directement
376
+ * ses pièces ; il l'ENVELOPPE quand il contient une .zv-nav — c'est la forme
377
+ * qu'impose une nav complète, dont les menus déroulants et le tiroir ont
378
+ * besoin d'un conteneur à eux. Le `:has` bascule tout seul, plutôt que de
379
+ * demander un modificateur qu'on oublierait.
380
+ *
381
+ * ⚠️ backdrop-filter fait de ce bloc le containing block de TOUT descendant
382
+ * en position fixe. Le tiroir (fixe lui aussi) doit donc vivre EN DEHORS,
383
+ * en frère — dedans, il serait rogné par la hauteur de la barre.
384
+ *
385
+ * Le voile venait en rgba littéral, c'est-à-dire hors token : --paper
386
+ * repeint, la barre serait restée sur l'ancienne couleur. */
318
387
  .zv-header {
319
388
  position: fixed;
320
389
  top: 0;
@@ -326,11 +395,25 @@
326
395
  padding-top: 22px;
327
396
  padding-bottom: 12px;
328
397
  z-index: 40;
329
- background: linear-gradient(180deg, rgba(245, 246, 250, 0.92), rgba(245, 246, 250, 0));
398
+ background: linear-gradient(
399
+ 180deg,
400
+ color-mix(in srgb, var(--paper) 92%, transparent),
401
+ color-mix(in srgb, var(--paper) 0%, transparent)
402
+ );
330
403
  backdrop-filter: blur(6px);
331
404
  -webkit-backdrop-filter: blur(6px);
332
405
  }
333
406
 
407
+ .zv-header:has(> .zv-nav) {
408
+ display: block;
409
+ padding-top: 0;
410
+ padding-bottom: 0;
411
+ }
412
+
413
+ .zv-header > .zv-nav {
414
+ padding-bottom: 14px;
415
+ }
416
+
334
417
  /* ─── Frise d'étapes ────────────────────────────────────────────────────
335
418
  * Troisième et dernier emploi autorisé du spectre : les points de méthode,
336
419
  * dans l'ordre iris → violet → cyan. */
@@ -711,3 +794,30 @@
711
794
  gap: 24px;
712
795
  }
713
796
  }
797
+
798
+ /* ─── Le seuil d'une nav riche ──────────────────────────────────────────
799
+ *
800
+ * La bascule vers le tiroir est à 767px, réglée sur la nav de référence :
801
+ * quatre liens simples. Une nav qui porte l'architecture d'un site — cinq
802
+ * familles à menu, un sélecteur de langue, un appel à l'action — déborde
803
+ * bien avant. Mesuré : entre 768 et 1150px, le logo passe SOUS la première
804
+ * entrée.
805
+ *
806
+ * `.zv-nav--riche` déplace le seuil à 1150px, POUR LA BARRE SEULEMENT. Ce
807
+ * n'est pas un second point de rupture de mise en page : le reste de la
808
+ * page garde l'unique seuil de la charte. C'est le seuil de débordement
809
+ * d'un composant, et il se déclare sur ce composant. */
810
+
811
+ @media (max-width: 1150px) {
812
+ .zv-nav--riche .zv-nav__links {
813
+ display: none;
814
+ }
815
+
816
+ .zv-nav--riche .zv-nav__burger {
817
+ display: inline-flex;
818
+ }
819
+
820
+ .zv-nav--riche .zv-nav__logo {
821
+ height: 30px;
822
+ }
823
+ }
package/src/nav.js ADDED
@@ -0,0 +1,67 @@
1
+ /* Zevra UI — nav.js : l'ouverture et la fermeture du tiroir de navigation.
2
+ *
3
+ * Rôle exact, et rien d'autre : poser et retirer `data-open` sur le tiroir,
4
+ * en tenir `aria-expanded` sur le bouton. Le reste — le voile, la glissade,
5
+ * le verrouillage du défilement — est dans overlays.css et se joue sur cet
6
+ * attribut seul.
7
+ *
8
+ * Le contrat, en quatre attributs :
9
+ * [data-zv-drawer] le tiroir (.zv-drawer)
10
+ * [data-zv-drawer-open] ce qui l'ouvre (le burger)
11
+ * [data-zv-drawer-close] ce qui le ferme (le voile, chaque lien)
12
+ * [data-zv-drawer="nom"] pour en câbler plusieurs sur une même page ;
13
+ * les déclencheurs portent alors la même valeur.
14
+ *
15
+ * Échap ferme : le CSS ne sait pas le faire, et un panneau qu'on ne ferme
16
+ * qu'en visant un voile est pénible au clavier — et impraticable sans souris.
17
+ *
18
+ * S'importe une fois par page : `import '@zevra/ui/nav.js'`. Idempotent, et
19
+ * sans effet si aucun tiroir n'est présent. Le module observe le document :
20
+ * un tiroir rendu plus tard (navigation client, îlot hydraté) est câblé sans
21
+ * qu'il y ait à réappeler quoi que ce soit.
22
+ */
23
+ (() => {
24
+ if (typeof document === 'undefined') return
25
+ if (document.documentElement.dataset.zvNav === 'on') return
26
+ document.documentElement.dataset.zvNav = 'on'
27
+
28
+ const tiroirs = (nom) =>
29
+ Array.from(document.querySelectorAll('[data-zv-drawer]')).filter(
30
+ (el) => !nom || el.getAttribute('data-zv-drawer') === nom,
31
+ )
32
+
33
+ const basculer = (nom, ouvert) => {
34
+ for (const tiroir of tiroirs(nom)) {
35
+ if (ouvert) tiroir.setAttribute('data-open', '')
36
+ else tiroir.removeAttribute('data-open')
37
+ }
38
+ const cle = nom || ''
39
+ for (const btn of document.querySelectorAll('[data-zv-drawer-open]')) {
40
+ if ((btn.getAttribute('data-zv-drawer-open') || '') === cle) {
41
+ btn.setAttribute('aria-expanded', String(ouvert))
42
+ }
43
+ }
44
+ }
45
+
46
+ // Délégation : un seul écouteur pour toute la page. Les liens du tiroir
47
+ // sont souvent rendus après coup — les câbler un par un obligerait à
48
+ // relancer le module à chaque fois.
49
+ document.addEventListener('click', (e) => {
50
+ const cible = e.target instanceof Element ? e.target : null
51
+ if (!cible) return
52
+ const ouvre = cible.closest('[data-zv-drawer-open]')
53
+ if (ouvre) {
54
+ basculer(ouvre.getAttribute('data-zv-drawer-open') || '', true)
55
+ return
56
+ }
57
+ const ferme = cible.closest('[data-zv-drawer-close]')
58
+ if (ferme) basculer(ferme.getAttribute('data-zv-drawer-close') || '', false)
59
+ })
60
+
61
+ document.addEventListener('keydown', (e) => {
62
+ if (e.key !== 'Escape') return
63
+ for (const tiroir of document.querySelectorAll('[data-zv-drawer][data-open]')) {
64
+ basculer(tiroir.getAttribute('data-zv-drawer') || '', false)
65
+ }
66
+ })
67
+ })()
@@ -496,6 +496,95 @@
496
496
  * clic extérieur, fermeture à Échap. Le CSS ci-dessus est prêt à les recevoir
497
497
  * (aria-selected, :focus-visible) ; le composant doit les écrire. */
498
498
 
499
+ /* ─── Entrées à menu déroulant de la barre vitrine ──────────────────────
500
+ *
501
+ * ⚠️ CE BLOC DOIT RESTER APRÈS `.zv-menu`, et donc dans CE fichier. Le
502
+ * panneau d'une entrée porte les deux classes ; `.zv-navdd__menu` reprend à
503
+ * `.zv-menu` son display, sa position et son ancrage, à spécificité égale —
504
+ * c'est l'ORDRE qui tranche. Posé dans layout.css, qu'index.css importe
505
+ * AVANT navigation.css, il perdait : les trois menus d'une barre
506
+ * s'affichaient en même temps, ouverts en permanence.
507
+ *
508
+ * Une nav de vitrine qui porte l'architecture d'un site — des familles,
509
+ * pas quatre liens — a besoin de déplier. Ouverture au survol ET au focus
510
+ * (`:focus-within`) : en CSS seul, donc accessible au clavier sans une
511
+ * ligne de JavaScript et sans état à tenir.
512
+ *
513
+ * Le panneau est le .zv-menu du paquet, simplement ancré sous l'entrée. */
514
+
515
+ .zv-navdd {
516
+ position: relative;
517
+ display: inline-flex;
518
+ }
519
+
520
+ /* Le bouton porte .zv-navlink : il doit se rendre EXACTEMENT comme un lien
521
+ * simple de la barre. Piège à éviter — un `font: inherit`, réflexe habituel
522
+ * pour dénuder un bouton, remet la fonte du CONTENEUR (16px/400) là où
523
+ * .zv-navlink pose 14px/500 : les entrées à menu deviennent plus grosses
524
+ * que les autres. On ne reprend donc du bouton que ce qui doit disparaître. */
525
+ .zv-navdd__btn {
526
+ display: inline-flex;
527
+ align-items: center;
528
+ gap: 5px;
529
+ padding: 0;
530
+ border: 0;
531
+ background: none;
532
+ cursor: pointer;
533
+ font-family: inherit;
534
+ line-height: inherit;
535
+ }
536
+
537
+ .zv-navdd__chevron {
538
+ width: 12px;
539
+ height: 12px;
540
+ fill: none;
541
+ stroke: currentColor;
542
+ stroke-width: 2;
543
+ /* Bouts francs : le trait carré du jeu d'icônes (25/08/2026). */
544
+ stroke-linecap: square;
545
+ stroke-linejoin: miter;
546
+ transition: transform 160ms var(--zv-easing, cubic-bezier(0.2, 0.7, 0.2, 1));
547
+ }
548
+
549
+ .zv-navdd:hover .zv-navdd__chevron,
550
+ .zv-navdd:focus-within .zv-navdd__chevron {
551
+ transform: rotate(180deg);
552
+ }
553
+
554
+ /* Le pont invisible (::before) couvre l'interstice entre l'entrée et son
555
+ * panneau : sans lui, le survol se rompt en descendant vers le menu et le
556
+ * menu se referme sous le curseur. */
557
+ .zv-navdd__menu {
558
+ position: absolute;
559
+ top: 100%;
560
+ left: 50%;
561
+ translate: -50% 0;
562
+ padding-top: 14px;
563
+ display: none;
564
+ min-width: 250px;
565
+ z-index: 50;
566
+ }
567
+
568
+ .zv-navdd__menu::before {
569
+ content: '';
570
+ position: absolute;
571
+ inset: 0 0 auto 0;
572
+ height: 14px;
573
+ }
574
+
575
+ .zv-navdd:hover .zv-navdd__menu,
576
+ .zv-navdd:focus-within .zv-navdd__menu {
577
+ display: flex;
578
+ flex-direction: column;
579
+ }
580
+
581
+ .zv-navdd__sep {
582
+ display: block;
583
+ height: 1px;
584
+ margin: 6px 0;
585
+ background: var(--hairline);
586
+ }
587
+
499
588
  /* ─── Popover ───────────────────────────────────────────────────────────
500
589
  * HORS RELEVÉ (25/08/2026, voir DECISIONS.md). Le menu impose ses items ;
501
590
  * il n'existait aucune surface ancrée LIBRE — aide contextuelle,