@zevra/ui 0.15.1 → 0.16.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
@@ -354,6 +354,59 @@ export function LockupWord({
354
354
  return <img alt={alt} className={cx('zv-lockup__word', className)} {...reste} />
355
355
  }
356
356
 
357
+ /* ─── Rail de lecture ──────────────────────────────────────────────── */
358
+
359
+ /** La table des matières d'une page longue, posée sur le côté. Elle navigue
360
+ * (chaque entrée est une ancre) et elle suit — poser `data-zv-rail` et
361
+ * importer `@zevra/ui/rail.js` pour que l'entrée courante s'allume. Sans le
362
+ * module, le rail navigue toujours : rien ne casse, le suivi manque.
363
+ *
364
+ * ⚠️ Elle demande de la place à côté du texte, et disparaît sous le point
365
+ * de rupture : un rail fixe sur un téléphone se pose sur la lecture. */
366
+ export function Rail({
367
+ gauche,
368
+ className,
369
+ ...reste
370
+ }: Omit<HTMLAttributes<HTMLElement>, 'className'> & {
371
+ gauche?: boolean
372
+ className?: string
373
+ 'aria-label': string
374
+ }) {
375
+ return (
376
+ <nav
377
+ data-zv-rail
378
+ className={cx('zv-rail zv-layer-chrome', gauche && 'zv-rail--gauche', className)}
379
+ {...reste}
380
+ />
381
+ )
382
+ }
383
+
384
+ /** ⚠️ `href` doit désigner une ancre de la page (`#section`) : c'est elle
385
+ * que le module surveille, et c'est le balisage qui fait foi — il n'y a
386
+ * pas de liste de sections à tenir à jour ailleurs. */
387
+ export function RailItem({
388
+ courant,
389
+ className,
390
+ children,
391
+ ...reste
392
+ }: Omit<HTMLAttributes<HTMLAnchorElement>, 'className'> & {
393
+ courant?: boolean
394
+ href: string
395
+ className?: string
396
+ children?: ReactNode
397
+ }) {
398
+ return (
399
+ <a
400
+ aria-current={courant ? 'true' : undefined}
401
+ className={cx('zv-rail__item', courant && 'zv-rail__item--on', className)}
402
+ {...reste}
403
+ >
404
+ <span className="zv-mono zv-mono--micro">{children}</span>
405
+ <span className="zv-rail__dot" aria-hidden="true" />
406
+ </a>
407
+ )
408
+ }
409
+
357
410
  /* ─── Frise d'étapes ───────────────────────────────────────────────── */
358
411
 
359
412
  export interface StepsProps extends Div {
package/src/layout.css CHANGED
@@ -795,6 +795,107 @@
795
795
  }
796
796
  }
797
797
 
798
+ /* ─── Rail de lecture ───────────────────────────────────────────────────
799
+ *
800
+ * La table des matières d'une page longue, posée sur le côté : un libellé,
801
+ * un point, et le point courant qui s'allume. Elle NAVIGUE (chaque entrée
802
+ * est une ancre) et elle SUIT (le module rail.js marque la section en
803
+ * cours) — les deux, sinon ce n'est qu'un décor qui occupe la marge.
804
+ *
805
+ * ⚠️ Elle demande de la place à côté du texte. Sous le point de rupture
806
+ * elle disparaît : un rail fixe sur un téléphone se pose sur la lecture.
807
+ * Au-dessus, c'est la mesure de la page qui décide — une page dont le
808
+ * contenu court d'un bord à l'autre jusqu'à 1100px gagnera à relever le
809
+ * seuil chez elle.
810
+ *
811
+ * L'empilement vient de .zv-layer-chrome, à poser avec : le rail est du
812
+ * chrome, il passe au-dessus des décors de scène. */
813
+
814
+ .zv-rail {
815
+ position: fixed;
816
+ right: 40px;
817
+ top: 50%;
818
+ translate: 0 -50%;
819
+ display: none;
820
+ flex-direction: column;
821
+ align-items: flex-end;
822
+ gap: 16px;
823
+ }
824
+
825
+ @media (min-width: 768px) {
826
+ .zv-rail {
827
+ display: flex;
828
+ }
829
+ }
830
+
831
+ /* Sur la gauche, l'ordre s'inverse : le point précède le libellé, sinon il
832
+ * se retrouve au milieu de la colonne au lieu d'en tenir le bord. */
833
+ .zv-rail--gauche {
834
+ right: auto;
835
+ left: 40px;
836
+ align-items: flex-start;
837
+ }
838
+
839
+ .zv-rail--gauche .zv-rail__item {
840
+ flex-direction: row-reverse;
841
+ }
842
+
843
+ .zv-rail__item {
844
+ display: flex;
845
+ align-items: center;
846
+ gap: 10px;
847
+ padding: 3px 0;
848
+ color: var(--muted);
849
+ text-decoration: none;
850
+ /* Un rail flotte au-dessus de ce que la page met dans sa marge : un
851
+ * libellé gris pâle sur un décor pâle devient illisible par manque de
852
+ * contraste, sans qu'aucun empilement soit en cause. Le halo couleur
853
+ * papier le détache de tout ce qui passe dessous, sans lui donner de
854
+ * cadre. */
855
+ text-shadow:
856
+ 0 0 6px var(--paper),
857
+ 0 0 12px var(--paper);
858
+ transition: color 160ms var(--zv-easing, cubic-bezier(0.2, 0.7, 0.2, 1));
859
+ }
860
+
861
+ /* Le libellé est posé au registre mono par l'appelant : il doit suivre la
862
+ * couleur de son entrée, pas garder la sienne. */
863
+ .zv-rail__item .zv-mono {
864
+ color: inherit;
865
+ }
866
+
867
+ .zv-rail__item:hover {
868
+ color: var(--accent-deep);
869
+ }
870
+
871
+ .zv-rail__dot {
872
+ width: 8px;
873
+ height: 8px;
874
+ flex: none;
875
+ border-radius: 50%;
876
+ background: var(--accent-line);
877
+ transition:
878
+ background 160ms var(--zv-easing, cubic-bezier(0.2, 0.7, 0.2, 1)),
879
+ scale 160ms var(--zv-easing, cubic-bezier(0.2, 0.7, 0.2, 1));
880
+ }
881
+
882
+ .zv-rail__item:hover .zv-rail__dot {
883
+ background: var(--accent);
884
+ scale: 1.35;
885
+ }
886
+
887
+ /* L'entrée courante, posée par rail.js. Le halo du point est le seul
888
+ * signal dont une navigation discrète a besoin — pas de fond, pas de
889
+ * filet. */
890
+ .zv-rail__item--on {
891
+ color: var(--accent-deep);
892
+ }
893
+
894
+ .zv-rail__item--on .zv-rail__dot {
895
+ background: var(--iris);
896
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--iris) 18%, transparent);
897
+ }
898
+
798
899
  /* ─── Le seuil d'une nav riche ──────────────────────────────────────────
799
900
  *
800
901
  * La bascule vers le tiroir est à 767px, réglée sur la nav de référence :
package/src/rail.js ADDED
@@ -0,0 +1,94 @@
1
+ /* Zevra UI — rail.js : le rail de lecture suit la section en cours.
2
+ *
3
+ * Rôle exact, et rien d'autre : poser `.zv-rail__item--on` et
4
+ * `aria-current` sur l'entrée dont la section est à l'écran. La navigation
5
+ * elle-même est du HTML — chaque entrée est une ancre, et elle fonctionne
6
+ * sans ce module.
7
+ *
8
+ * Le contrat, en un attribut :
9
+ * [data-zv-rail] le rail (.zv-rail). Ses entrées sont ses <a href="#…">,
10
+ * et chaque href désigne la cible à surveiller.
11
+ *
12
+ * Rien à déclarer d'autre : l'ordre des entrées, leurs cibles et leur
13
+ * nombre se lisent dans le balisage. Un rail dont une ancre ne résout pas
14
+ * garde l'entrée cliquable et l'ignore au suivi, plutôt que de décaler
15
+ * tout le reste — c'est le décalage silencieux qui coûte cher.
16
+ *
17
+ * ─── Pourquoi pas un IntersectionObserver ───────────────────────────────
18
+ * Parce que les ancres d'un rail sont souvent des points MATÉRIELS de 1px
19
+ * posés en tête de section. Réduit à sa bande centrale, l'observateur ne
20
+ * les voit traverser que pendant quelques images — un défilement rapide
21
+ * saute la bande entière et l'entrée ne s'allume jamais. On lit donc la
22
+ * position : l'entrée courante est la DERNIÈRE dont l'ancre a passé la
23
+ * ligne de lecture. C'est déterministe, quelle que soit la vitesse.
24
+ *
25
+ * S'importe une fois par page : `import '@zevra/ui/rail.js'`. Sans lui, le
26
+ * rail navigue mais ne suit pas — dégradation douce, pas de page cassée.
27
+ */
28
+ (() => {
29
+ if (typeof document === 'undefined') return
30
+ if (document.documentElement.dataset.zvRail === 'on') return
31
+ document.documentElement.dataset.zvRail = 'on'
32
+
33
+ /** Hauteur de la ligne de lecture, en fraction du viewport. 45 % : un peu
34
+ * au-dessus du milieu, là où l'œil se pose vraiment. */
35
+ const LIGNE = 0.45
36
+
37
+ const rails = () => Array.from(document.querySelectorAll('[data-zv-rail]'))
38
+
39
+ const suivre = (rail) => {
40
+ const entrees = Array.from(rail.querySelectorAll('a[href^="#"]'))
41
+ if (!entrees.length) return
42
+
43
+ const cibles = entrees.map((a) => {
44
+ const id = decodeURIComponent(a.getAttribute('href').slice(1))
45
+ return id ? document.getElementById(id) : null
46
+ })
47
+ if (!cibles.some(Boolean)) return
48
+
49
+ let courant = -1
50
+ const marquer = (i) => {
51
+ if (i === courant) return
52
+ courant = i
53
+ entrees.forEach((a, k) => {
54
+ a.classList.toggle('zv-rail__item--on', k === i)
55
+ if (k === i) a.setAttribute('aria-current', 'true')
56
+ else a.removeAttribute('aria-current')
57
+ })
58
+ }
59
+
60
+ const relire = () => {
61
+ const ligne = window.innerHeight * LIGNE
62
+ let actif = 0
63
+ for (let i = 0; i < cibles.length; i++) {
64
+ const c = cibles[i]
65
+ if (c && c.getBoundingClientRect().top <= ligne) actif = i
66
+ }
67
+ marquer(actif)
68
+ }
69
+
70
+ // Une lecture par image au plus : le défilement émet bien plus souvent
71
+ // que l'écran ne se rafraîchit, et chaque lecture coûte un reflow.
72
+ let prevu = false
73
+ const planifier = () => {
74
+ if (prevu) return
75
+ prevu = true
76
+ requestAnimationFrame(() => {
77
+ prevu = false
78
+ relire()
79
+ })
80
+ }
81
+
82
+ addEventListener('scroll', planifier, { passive: true })
83
+ addEventListener('resize', planifier, { passive: true })
84
+ relire()
85
+ }
86
+
87
+ const demarrer = () => rails().forEach(suivre)
88
+
89
+ if (document.readyState === 'loading') {
90
+ document.addEventListener('DOMContentLoaded', demarrer, { once: true })
91
+ } else {
92
+ demarrer()
93
+ }
94
+ })()