@zevra/ui 0.13.0 → 0.15.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/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 {
@@ -967,3 +967,65 @@
967
967
  font-size: 21px;
968
968
  }
969
969
  }
970
+
971
+ /* ─── Le lockup : la marque et le mot ───────────────────────────────────
972
+ *
973
+ * Jusqu'ici le nom d'un produit voyageait cuit dans un PNG, marque et mot
974
+ * ensemble. Un lockup de 555×200 dont la capitale mesure 70px se retrouvait
975
+ * affiché à 15px de capitale dans une barre de navigation et 10px dans un
976
+ * pied de page : réduit de près de cinq fois, chaque fût tombait sur une
977
+ * fraction de pixel et recevait sa propre dose de lissage. Le mot devenait
978
+ * gris et le V, seul à n'avoir aucun fût vertical sur quoi s'aligner,
979
+ * semblait flotter.
980
+ *
981
+ * Le mot est donc un SVG (`logos/svg/<produit>-wordmark.svg`), à
982
+ * `currentColor` : net à toute taille, et il prend enfin les nuances encre
983
+ * / navy / blanc que le registre des logos décrit. La marque reste le PNG
984
+ * couleur du paquet — c'est un dégradé, il ne se recolore pas.
985
+ *
986
+ * ⚠️ Ces tracés sont RELEVÉS sur le lockup bitmap d'origine, faute de son
987
+ * fichier vectoriel. Ils sont fidèles à 0,1 % en largeur et nettement plus
988
+ * lisibles que le bitmap partout où le site les pose, mais ils portent les
989
+ * approximations du relevé : réservés à l'ÉCRAN, jusqu'à 120px de capitale.
990
+ * Au-delà — grand format, impression — exiger le fichier d'origine.
991
+ *
992
+ * Les proportions viennent du lockup, mesurées au pixel : la marque vaut
993
+ * 2,86 fois la capitale, l'écart entre les deux 0,64 fois, et les deux
994
+ * pièces se centrent l'une sur l'autre (elles ne partagent pas de ligne de
995
+ * base). Tout se pilote par --zv-cap, qui EST la hauteur de capitale : le
996
+ * SVG du mot a un viewBox dont la hauteur vaut la capitale, si bien qu'une
997
+ * hauteur CSS posée dessus se lit directement. */
998
+
999
+ .zv-lockup {
1000
+ --zv-cap: 15px;
1001
+ display: inline-flex;
1002
+ align-items: center;
1003
+ gap: calc(var(--zv-cap) * 0.64);
1004
+ color: var(--ink);
1005
+ text-decoration: none;
1006
+ }
1007
+
1008
+ .zv-lockup__mark {
1009
+ height: calc(var(--zv-cap) * 2.86);
1010
+ width: auto;
1011
+ display: block;
1012
+ }
1013
+
1014
+ .zv-lockup__word {
1015
+ height: var(--zv-cap);
1016
+ width: auto;
1017
+ display: block;
1018
+ }
1019
+
1020
+ /* Le seuil de lisibilité. Sous 11px de capitale, le mot ne se lit plus :
1021
+ * ce n'est plus une question de netteté mais de taille, et aucun fichier
1022
+ * n'y changera rien. On montre la marque, seule — c'est elle qui identifie.
1023
+ * Poser cette classe, ou masquer .zv-lockup__word sous un point de rupture
1024
+ * quand la barre se resserre. */
1025
+ .zv-lockup--marque-seule {
1026
+ gap: 0;
1027
+ }
1028
+
1029
+ .zv-lockup--marque-seule .zv-lockup__word {
1030
+ display: none;
1031
+ }
package/src/layout.css CHANGED
@@ -313,8 +313,159 @@
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
+ /* ─── Entrées à menu déroulant ──────────────────────────────────────────
317
+ *
318
+ * Une nav de vitrine qui porte l'architecture d'un site — des familles,
319
+ * pas quatre liens — a besoin de déplier. Ouverture au survol ET au focus
320
+ * (`:focus-within`) : en CSS seul, donc accessible au clavier sans une
321
+ * ligne de JavaScript et sans état à tenir.
322
+ *
323
+ * Le panneau est le .zv-menu du paquet, simplement ancré sous l'entrée. */
324
+
325
+ .zv-navdd {
326
+ position: relative;
327
+ display: inline-flex;
328
+ }
329
+
330
+ /* Le bouton porte .zv-navlink : il doit se rendre EXACTEMENT comme un lien
331
+ * simple de la barre. Piège à éviter — un `font: inherit`, réflexe habituel
332
+ * pour dénuder un bouton, remet la fonte du CONTENEUR (16px/400) là où
333
+ * .zv-navlink pose 14px/500 : les entrées à menu deviennent plus grosses
334
+ * que les autres. On ne reprend donc du bouton que ce qui doit disparaître. */
335
+ .zv-navdd__btn {
336
+ display: inline-flex;
337
+ align-items: center;
338
+ gap: 5px;
339
+ padding: 0;
340
+ border: 0;
341
+ background: none;
342
+ cursor: pointer;
343
+ font-family: inherit;
344
+ line-height: inherit;
345
+ }
346
+
347
+ .zv-navdd__chevron {
348
+ width: 12px;
349
+ height: 12px;
350
+ fill: none;
351
+ stroke: currentColor;
352
+ stroke-width: 2;
353
+ /* Bouts francs : le trait carré du jeu d'icônes (25/08/2026). */
354
+ stroke-linecap: square;
355
+ stroke-linejoin: miter;
356
+ transition: transform 160ms var(--zv-easing, cubic-bezier(0.2, 0.7, 0.2, 1));
357
+ }
358
+
359
+ .zv-navdd:hover .zv-navdd__chevron,
360
+ .zv-navdd:focus-within .zv-navdd__chevron {
361
+ transform: rotate(180deg);
362
+ }
363
+
364
+ /* Le pont invisible (::before) couvre l'interstice entre l'entrée et son
365
+ * panneau : sans lui, le survol se rompt en descendant vers le menu et le
366
+ * menu se referme sous le curseur. */
367
+ .zv-navdd__menu {
368
+ position: absolute;
369
+ top: 100%;
370
+ left: 50%;
371
+ translate: -50% 0;
372
+ padding-top: 14px;
373
+ display: none;
374
+ min-width: 250px;
375
+ z-index: 50;
376
+ }
377
+
378
+ .zv-navdd__menu::before {
379
+ content: '';
380
+ position: absolute;
381
+ inset: 0 0 auto 0;
382
+ height: 14px;
383
+ }
384
+
385
+ .zv-navdd:hover .zv-navdd__menu,
386
+ .zv-navdd:focus-within .zv-navdd__menu {
387
+ display: flex;
388
+ flex-direction: column;
389
+ }
390
+
391
+ .zv-navdd__sep {
392
+ display: block;
393
+ height: 1px;
394
+ margin: 6px 0;
395
+ background: var(--hairline);
396
+ }
397
+
398
+ /* ─── Sélecteur de langue ───────────────────────────────────────────────
399
+ * Des initiales au registre mono, pas un menu : trois langues se lisent
400
+ * d'un coup d'œil, et un menu pour trois entrées coûte un clic pour rien.
401
+ * Une page qui n'existe que dans une langue ne le pose pas — il vaut mieux
402
+ * pas de sélecteur qu'un sélecteur qui mène à des 404. */
403
+
404
+ .zv-langues {
405
+ display: inline-flex;
406
+ align-items: center;
407
+ gap: 10px;
408
+ }
409
+
410
+ .zv-langue {
411
+ color: var(--label);
412
+ text-decoration: none;
413
+ letter-spacing: 0.12em;
414
+ transition: color 160ms var(--zv-easing, cubic-bezier(0.2, 0.7, 0.2, 1));
415
+ }
416
+
417
+ .zv-langue:hover {
418
+ color: var(--accent);
419
+ }
420
+
421
+ .zv-langue--on {
422
+ color: var(--ink);
423
+ font-weight: 700;
424
+ }
425
+
426
+ /* Dans le tiroir, il se détache en pied de liste. */
427
+ .zv-langues--tiroir {
428
+ display: flex;
429
+ gap: 16px;
430
+ margin-top: 18px;
431
+ padding-top: 14px;
432
+ border-top: 1px solid var(--hairline);
433
+ }
434
+
435
+ /* ─── Le tiroir, groupé ─────────────────────────────────────────────────
436
+ * Quand la barre porte des familles, les aplatir en une liste unique rend
437
+ * le tiroir illisible. Un label capitale par famille, ses entrées en
438
+ * dessous : la même architecture, repliée. */
439
+
440
+ .zv-nav-drawer__groupe {
441
+ display: block;
442
+ margin: 16px 0 4px;
443
+ }
444
+
445
+ .zv-nav-drawer__groupe:first-child {
446
+ margin-top: 0;
447
+ }
448
+
449
+ .zv-nav-drawer__link--sous {
450
+ font-size: 15px;
451
+ padding-block: 7px;
452
+ }
453
+
454
+ /* Chrome fixe : voile dégradé plutôt qu'un fond plein, pour que le contenu
455
+ * s'y fonde au défilement.
456
+ *
457
+ * Deux usages, une seule classe. Il est LA BARRE quand il porte directement
458
+ * ses pièces ; il l'ENVELOPPE quand il contient une .zv-nav — c'est la forme
459
+ * qu'impose une nav complète, dont les menus déroulants et le tiroir ont
460
+ * besoin d'un conteneur à eux. Le `:has` bascule tout seul, plutôt que de
461
+ * demander un modificateur qu'on oublierait.
462
+ *
463
+ * ⚠️ backdrop-filter fait de ce bloc le containing block de TOUT descendant
464
+ * en position fixe. Le tiroir (fixe lui aussi) doit donc vivre EN DEHORS,
465
+ * en frère — dedans, il serait rogné par la hauteur de la barre.
466
+ *
467
+ * Le voile venait en rgba littéral, c'est-à-dire hors token : --paper
468
+ * repeint, la barre serait restée sur l'ancienne couleur. */
318
469
  .zv-header {
319
470
  position: fixed;
320
471
  top: 0;
@@ -326,11 +477,25 @@
326
477
  padding-top: 22px;
327
478
  padding-bottom: 12px;
328
479
  z-index: 40;
329
- background: linear-gradient(180deg, rgba(245, 246, 250, 0.92), rgba(245, 246, 250, 0));
480
+ background: linear-gradient(
481
+ 180deg,
482
+ color-mix(in srgb, var(--paper) 92%, transparent),
483
+ color-mix(in srgb, var(--paper) 0%, transparent)
484
+ );
330
485
  backdrop-filter: blur(6px);
331
486
  -webkit-backdrop-filter: blur(6px);
332
487
  }
333
488
 
489
+ .zv-header:has(> .zv-nav) {
490
+ display: block;
491
+ padding-top: 0;
492
+ padding-bottom: 0;
493
+ }
494
+
495
+ .zv-header > .zv-nav {
496
+ padding-bottom: 14px;
497
+ }
498
+
334
499
  /* ─── Frise d'étapes ────────────────────────────────────────────────────
335
500
  * Troisième et dernier emploi autorisé du spectre : les points de méthode,
336
501
  * dans l'ordre iris → violet → cyan. */
@@ -711,3 +876,30 @@
711
876
  gap: 24px;
712
877
  }
713
878
  }
879
+
880
+ /* ─── Le seuil d'une nav riche ──────────────────────────────────────────
881
+ *
882
+ * La bascule vers le tiroir est à 767px, réglée sur la nav de référence :
883
+ * quatre liens simples. Une nav qui porte l'architecture d'un site — cinq
884
+ * familles à menu, un sélecteur de langue, un appel à l'action — déborde
885
+ * bien avant. Mesuré : entre 768 et 1150px, le logo passe SOUS la première
886
+ * entrée.
887
+ *
888
+ * `.zv-nav--riche` déplace le seuil à 1150px, POUR LA BARRE SEULEMENT. Ce
889
+ * n'est pas un second point de rupture de mise en page : le reste de la
890
+ * page garde l'unique seuil de la charte. C'est le seuil de débordement
891
+ * d'un composant, et il se déclare sur ce composant. */
892
+
893
+ @media (max-width: 1150px) {
894
+ .zv-nav--riche .zv-nav__links {
895
+ display: none;
896
+ }
897
+
898
+ .zv-nav--riche .zv-nav__burger {
899
+ display: inline-flex;
900
+ }
901
+
902
+ .zv-nav--riche .zv-nav__logo {
903
+ height: 30px;
904
+ }
905
+ }
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
+ })()
package/src/tokens.css CHANGED
@@ -68,7 +68,7 @@
68
68
  --err-line: #efc9cd;
69
69
 
70
70
  /* Typo — Google Fonts : Outfit (display), Public Sans (texte), Space Grotesk (labels) */
71
- --font-display: "Outfit", "Helvetica Neue", Arial, sans-serif; /* titres 200-400, wordmarks 600 */
71
+ --font-display: "Outfit", "Helvetica Neue", Arial, sans-serif; /* titres 200-400 */
72
72
  --font-text: "Public Sans", "Helvetica Neue", Arial, sans-serif; /* 400/600 */
73
73
  --font-mono: "Space Grotesk", "Menlo", monospace; /* labels caps, 0.18-0.2em */
74
74
  /* Chasse fixe RÉELLE, pour le code seulement. --font-mono (Space Grotesk)