@zevra/ui 0.15.1 → 0.17.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/DECISIONS.md +82 -0
- package/dist/catalogue.d.ts.map +1 -1
- package/dist/catalogue.js +15 -1
- package/dist/catalogue.js.map +1 -1
- package/dist/css-inline.d.ts.map +1 -1
- package/dist/css-inline.js +1 -1
- package/dist/css-inline.js.map +1 -1
- package/dist/layout.d.ts +21 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +17 -0
- package/dist/layout.js.map +1 -1
- package/logos/favicon/campus/apple-touch-icon.png +0 -0
- package/logos/favicon/campus/favicon.ico +0 -0
- package/logos/favicon/campus/icon-192.png +0 -0
- package/logos/favicon/campus/icon-512.png +0 -0
- package/logos/favicon/lexform/apple-touch-icon.png +0 -0
- package/logos/favicon/lexform/favicon.ico +0 -0
- package/logos/favicon/lexform/icon-192.png +0 -0
- package/logos/favicon/lexform/icon-512.png +0 -0
- package/logos/favicon/mcp/apple-touch-icon.png +0 -0
- package/logos/favicon/mcp/favicon.ico +0 -0
- package/logos/favicon/mcp/icon-192.png +0 -0
- package/logos/favicon/mcp/icon-512.png +0 -0
- package/logos/favicon/memoire/apple-touch-icon.png +0 -0
- package/logos/favicon/memoire/favicon.ico +0 -0
- package/logos/favicon/memoire/icon-192.png +0 -0
- package/logos/favicon/memoire/icon-512.png +0 -0
- package/logos/favicon/plume/apple-touch-icon.png +0 -0
- package/logos/favicon/plume/favicon.ico +0 -0
- package/logos/favicon/plume/icon-192.png +0 -0
- package/logos/favicon/plume/icon-512.png +0 -0
- package/logos/favicon/supernovia/apple-touch-icon.png +0 -0
- package/logos/favicon/supernovia/favicon.ico +0 -0
- package/logos/favicon/supernovia/icon-192.png +0 -0
- package/logos/favicon/supernovia/icon-512.png +0 -0
- package/logos/favicon/zevra/apple-touch-icon.png +0 -0
- package/logos/favicon/zevra/favicon.ico +0 -0
- package/logos/favicon/zevra/icon-192.png +0 -0
- package/logos/favicon/zevra/icon-512.png +0 -0
- package/package.json +4 -2
- package/react/catalogue.tsx +39 -1
- package/react/css-inline.ts +1 -1
- package/react/layout.tsx +53 -0
- package/src/layout.css +101 -0
- package/src/rail.js +94 -0
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
|
+
})()
|