@zevra/ui 0.4.0 → 0.5.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/README.md +64 -1
- package/dist/actions.d.ts +41 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +24 -0
- package/dist/actions.js.map +1 -0
- package/dist/catalogue.d.ts +30 -0
- package/dist/catalogue.d.ts.map +1 -0
- package/dist/catalogue.js +830 -0
- package/dist/catalogue.js.map +1 -0
- package/dist/content.d.ts +135 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +85 -0
- package/dist/content.js.map +1 -0
- package/dist/css-inline.d.ts +11 -0
- package/dist/css-inline.d.ts.map +1 -0
- package/dist/css-inline.js +13 -0
- package/dist/css-inline.js.map +1 -0
- package/dist/cx.d.ts +16 -0
- package/dist/cx.d.ts.map +1 -0
- package/dist/cx.js +20 -0
- package/dist/cx.js.map +1 -0
- package/dist/data.d.ts +136 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +98 -0
- package/dist/data.js.map +1 -0
- package/dist/feedback.d.ts +88 -0
- package/dist/feedback.d.ts.map +1 -0
- package/dist/feedback.js +38 -0
- package/dist/feedback.js.map +1 -0
- package/dist/forms.d.ts +120 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +60 -0
- package/dist/forms.js.map +1 -0
- package/dist/icon-data.d.ts +6 -0
- package/dist/icon-data.d.ts.map +1 -0
- package/dist/icon-data.js +77 -0
- package/dist/icon-data.js.map +1 -0
- package/dist/icon.d.ts +33 -0
- package/dist/icon.d.ts.map +1 -0
- package/dist/icon.js +13 -0
- package/dist/icon.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +166 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +120 -0
- package/dist/layout.js.map +1 -0
- package/dist/navigation.d.ts +150 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +104 -0
- package/dist/navigation.js.map +1 -0
- package/dist/overlays.d.ts +68 -0
- package/dist/overlays.d.ts.map +1 -0
- package/dist/overlays.js +114 -0
- package/dist/overlays.js.map +1 -0
- package/dist/surfaces.d.ts +61 -0
- package/dist/surfaces.d.ts.map +1 -0
- package/dist/surfaces.js +54 -0
- package/dist/surfaces.js.map +1 -0
- package/dist/typography.d.ts +89 -0
- package/dist/typography.d.ts.map +1 -0
- package/dist/typography.js +76 -0
- package/dist/typography.js.map +1 -0
- package/package.json +36 -4
- package/react/actions.tsx +138 -0
- package/react/catalogue.tsx +1644 -0
- package/react/content.tsx +313 -0
- package/react/css-inline.ts +14 -0
- package/react/cx.ts +20 -0
- package/react/data.tsx +300 -0
- package/react/feedback.tsx +229 -0
- package/react/forms.tsx +383 -0
- package/react/icon-data.ts +114 -0
- package/react/icon.tsx +84 -0
- package/react/index.ts +42 -0
- package/react/layout.tsx +347 -0
- package/react/navigation.tsx +329 -0
- package/react/overlays.tsx +237 -0
- package/react/surfaces.tsx +161 -0
- package/react/typography.tsx +220 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Enrobages — voile, tiroir, modale, notification flottante.
|
|
4
|
+
//
|
|
5
|
+
// SEUL fichier du paquet marqué « use client », avec un hook. Tout ce qui vit
|
|
6
|
+
// ici vole le regard à la page : il faut une touche Échap, un piège de focus
|
|
7
|
+
// et un verrou de défilement, et rien de cela ne s'écrit en CSS.
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ Ne PAS écrire `data-open={ouvert}` en React : cela rend `data-open="false"`,
|
|
10
|
+
// que le sélecteur d'attribut du CSS considère comme PRÉSENT — le panneau
|
|
11
|
+
// resterait ouvert. Ces composants emploient la classe `--open`, prévue pour ça.
|
|
12
|
+
|
|
13
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
14
|
+
import type { HTMLAttributes, ReactNode, RefObject } from 'react'
|
|
15
|
+
import { cx } from './cx.js'
|
|
16
|
+
|
|
17
|
+
type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
|
|
18
|
+
|
|
19
|
+
const FOCUSABLES =
|
|
20
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
|
21
|
+
|
|
22
|
+
export interface UseOverlayOptions {
|
|
23
|
+
ouvert: boolean
|
|
24
|
+
onClose: () => void
|
|
25
|
+
/** Le nœud à piéger. Sans lui, pas de piège — mais Échap fonctionne quand
|
|
26
|
+
* même. */
|
|
27
|
+
panneau?: RefObject<HTMLElement | null>
|
|
28
|
+
/** Verrouille le défilement du document. Vrai pour une modale, vrai pour un
|
|
29
|
+
* tiroir mobile ; faux pour ce qui ne recouvre pas la page. */
|
|
30
|
+
verrouille?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Échap, piège de focus, restitution du focus, verrou de défilement.
|
|
34
|
+
* Exporté : une app qui dessine son propre enrobage doit pouvoir reprendre
|
|
35
|
+
* ce comportement sans recopier trente lignes qui se trompent une fois sur
|
|
36
|
+
* deux. */
|
|
37
|
+
export function useOverlay({ ouvert, onClose, panneau, verrouille = true }: UseOverlayOptions) {
|
|
38
|
+
// Le nœud qui avait le focus AVANT l'ouverture : c'est à lui qu'il revient
|
|
39
|
+
// à la fermeture. Sans cela, le focus repart au début du document et un
|
|
40
|
+
// utilisateur au clavier perd sa place dans la page.
|
|
41
|
+
const origine = useRef<HTMLElement | null>(null)
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!ouvert) return
|
|
45
|
+
const cible = panneau?.current
|
|
46
|
+
// ⚠️ Le document du PANNEAU, pas `document` : un enrobage peut vivre dans
|
|
47
|
+
// un autre document que celui du script (planche de composants rendue dans
|
|
48
|
+
// un iframe, fenêtre détachée). Écouter `document` y poserait la touche
|
|
49
|
+
// Échap sur la page hôte — elle fermerait la modale d'à côté et pas
|
|
50
|
+
// celle-ci. Sans panneau, on retombe sur le document courant.
|
|
51
|
+
const docu = cible?.ownerDocument ?? document
|
|
52
|
+
origine.current = docu.activeElement as HTMLElement | null
|
|
53
|
+
|
|
54
|
+
// ⚠️ La valeur PRÉCÉDENTE est restaurée, pas remise à '' : une page qui
|
|
55
|
+
// gérait déjà son propre défilement serait cassée par un reset aveugle.
|
|
56
|
+
const overflowPrecedent = verrouille ? docu.body.style.overflow : null
|
|
57
|
+
if (verrouille) docu.body.style.overflow = 'hidden'
|
|
58
|
+
|
|
59
|
+
const premier = cible?.querySelector<HTMLElement>(FOCUSABLES)
|
|
60
|
+
premier?.focus()
|
|
61
|
+
|
|
62
|
+
function onKey(e: KeyboardEvent) {
|
|
63
|
+
if (e.key === 'Escape') {
|
|
64
|
+
e.stopPropagation()
|
|
65
|
+
onClose()
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
if (e.key !== 'Tab' || !cible) return
|
|
69
|
+
const noeuds = Array.from(cible.querySelectorAll<HTMLElement>(FOCUSABLES)).filter(
|
|
70
|
+
(n) => n.offsetParent !== null || n === docu.activeElement,
|
|
71
|
+
)
|
|
72
|
+
if (noeuds.length === 0) return
|
|
73
|
+
const debut = noeuds[0]!
|
|
74
|
+
const fin = noeuds[noeuds.length - 1]!
|
|
75
|
+
// Le cycle est refermé à la main : sans cela, Tab sort du panneau et
|
|
76
|
+
// l'utilisateur se retrouve à parcourir la page cachée derrière le voile.
|
|
77
|
+
if (e.shiftKey && docu.activeElement === debut) {
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
fin.focus()
|
|
80
|
+
} else if (!e.shiftKey && docu.activeElement === fin) {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
debut.focus()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
docu.addEventListener('keydown', onKey)
|
|
87
|
+
return () => {
|
|
88
|
+
docu.removeEventListener('keydown', onKey)
|
|
89
|
+
if (overflowPrecedent !== null) docu.body.style.overflow = overflowPrecedent
|
|
90
|
+
origine.current?.focus?.()
|
|
91
|
+
}
|
|
92
|
+
}, [ouvert, onClose, panneau, verrouille])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ─── Voile ────────────────────────────────────────────────────────── */
|
|
96
|
+
|
|
97
|
+
/** Toujours l'encre de charte diluée, jamais un noir pur : la page est
|
|
98
|
+
* bleutée, un gris neutre y ferait un trou sale. */
|
|
99
|
+
export function Scrim({ className, ...reste }: Div & { className?: string }) {
|
|
100
|
+
return <div className={cx('zv-scrim', className)} aria-hidden="true" {...reste} />
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ─── Tiroir ───────────────────────────────────────────────────────── */
|
|
104
|
+
|
|
105
|
+
export interface DrawerProps extends Omit<Div, 'title'> {
|
|
106
|
+
ouvert: boolean
|
|
107
|
+
onClose: () => void
|
|
108
|
+
/** Nom du dialogue — ce que le lecteur d'écran annonce à l'ouverture. */
|
|
109
|
+
label?: string
|
|
110
|
+
/** Croix de fermeture posée en absolu par-dessus la tête du panneau. */
|
|
111
|
+
fermeture?: ReactNode
|
|
112
|
+
className?: string
|
|
113
|
+
children?: ReactNode
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** ⚠️ Le tiroir n'existe QUE sous le point de rupture : au-dessus, la
|
|
117
|
+
* navigation est une colonne et il n'y a rien à ouvrir. Le CSS s'en charge —
|
|
118
|
+
* ne pas conditionner le rendu sur la largeur en JavaScript, le serveur ne
|
|
119
|
+
* la connaît pas. */
|
|
120
|
+
export function Drawer({
|
|
121
|
+
ouvert,
|
|
122
|
+
onClose,
|
|
123
|
+
label = 'Navigation',
|
|
124
|
+
fermeture,
|
|
125
|
+
className,
|
|
126
|
+
children,
|
|
127
|
+
...reste
|
|
128
|
+
}: DrawerProps) {
|
|
129
|
+
const panneau = useRef<HTMLDivElement>(null)
|
|
130
|
+
useOverlay({ ouvert, onClose, panneau })
|
|
131
|
+
const fermer = useCallback(() => onClose(), [onClose])
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
role="dialog"
|
|
136
|
+
aria-modal="true"
|
|
137
|
+
aria-label={label}
|
|
138
|
+
aria-hidden={!ouvert}
|
|
139
|
+
className={cx('zv-drawer', ouvert && 'zv-drawer--open', className)}
|
|
140
|
+
{...reste}
|
|
141
|
+
>
|
|
142
|
+
<div className="zv-scrim" onClick={fermer} aria-hidden="true" />
|
|
143
|
+
<div className="zv-drawer__panel" ref={panneau}>
|
|
144
|
+
{fermeture && <div className="zv-drawer__close">{fermeture}</div>}
|
|
145
|
+
{children}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ─── Modale ───────────────────────────────────────────────────────── */
|
|
152
|
+
|
|
153
|
+
export interface ModalProps extends Omit<Div, 'title'> {
|
|
154
|
+
ouvert: boolean
|
|
155
|
+
onClose: () => void
|
|
156
|
+
titre?: ReactNode
|
|
157
|
+
/** Actions de tête, à droite du titre (la croix, en général). */
|
|
158
|
+
actions?: ReactNode
|
|
159
|
+
/** Pied d'actions. Il reste visible quand le corps défile — c'est tout
|
|
160
|
+
* l'intérêt du plafond de hauteur. */
|
|
161
|
+
pied?: ReactNode
|
|
162
|
+
/** Modale destructive : le filet HAUT porte l'avertissement, pas un fond
|
|
163
|
+
* rouge — une surface d'alerte pleine rendrait le texte pénible à lire. */
|
|
164
|
+
danger?: boolean
|
|
165
|
+
className?: string
|
|
166
|
+
children?: ReactNode
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function Modal({
|
|
170
|
+
ouvert,
|
|
171
|
+
onClose,
|
|
172
|
+
titre,
|
|
173
|
+
actions,
|
|
174
|
+
pied,
|
|
175
|
+
danger,
|
|
176
|
+
className,
|
|
177
|
+
children,
|
|
178
|
+
...reste
|
|
179
|
+
}: ModalProps) {
|
|
180
|
+
const panneau = useRef<HTMLDivElement>(null)
|
|
181
|
+
useOverlay({ ouvert, onClose, panneau })
|
|
182
|
+
const fermer = useCallback(() => onClose(), [onClose])
|
|
183
|
+
|
|
184
|
+
// Une modale fermée n'est pas rendue du tout : contrairement au tiroir, elle
|
|
185
|
+
// n'a pas d'animation d'entrée à préserver, et son contenu (un formulaire,
|
|
186
|
+
// le plus souvent) ne doit pas rester dans le DOM à l'état fantôme.
|
|
187
|
+
if (!ouvert) return null
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div
|
|
191
|
+
role="dialog"
|
|
192
|
+
aria-modal="true"
|
|
193
|
+
aria-label={typeof titre === 'string' ? titre : undefined}
|
|
194
|
+
className={cx('zv-modal', 'zv-modal--open', danger && 'zv-modal--danger', className)}
|
|
195
|
+
{...reste}
|
|
196
|
+
>
|
|
197
|
+
<div className="zv-scrim" onClick={fermer} aria-hidden="true" />
|
|
198
|
+
<div className="zv-modal__panel" ref={panneau}>
|
|
199
|
+
{(titre || actions) && (
|
|
200
|
+
<div className="zv-modal__head">
|
|
201
|
+
{titre && <h2 className="zv-modal__title">{titre}</h2>}
|
|
202
|
+
{actions}
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
<div className="zv-modal__body">{children}</div>
|
|
206
|
+
{pied && <div className="zv-modal__foot">{pied}</div>}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ─── Notification flottante ───────────────────────────────────────── */
|
|
213
|
+
|
|
214
|
+
/** Le conteneur ne capte RIEN : seules les notifications le font, sans quoi
|
|
215
|
+
* une colonne invisible bloquerait les clics dans tout le coin de l'écran. */
|
|
216
|
+
export function Toasts({ className, ...reste }: Div & { className?: string }) {
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
className={cx('zv-toasts', className)}
|
|
220
|
+
role="region"
|
|
221
|
+
aria-live="polite"
|
|
222
|
+
{...reste}
|
|
223
|
+
/>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface ToastProps extends Div {
|
|
228
|
+
ton?: 'ok' | 'err'
|
|
229
|
+
className?: string
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** ⚠️ Ne remplace PAS `Alert` : une erreur de formulaire se dit à côté du
|
|
233
|
+
* champ, jamais dans un coin qui disparaît. Réserver ce composant à ce qui
|
|
234
|
+
* est vrai APRÈS COUP — « copié », « enregistré », « envoyé ». */
|
|
235
|
+
export function Toast({ ton, className, ...reste }: ToastProps) {
|
|
236
|
+
return <div className={cx('zv-toast', ton && `zv-toast--${ton}`, className)} {...reste} />
|
|
237
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Surfaces — halos, sols quadrillés, voiles de fondu, bandeau manifeste,
|
|
2
|
+
// glyphes. C'est ce qui distingue une page Zevra d'une page blanche bien
|
|
3
|
+
// composée.
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ TOUT est DÉCORATIF : posé en absolu derrière le contenu, sans capter le
|
|
6
|
+
// pointeur, et masqué aux lecteurs d'écran. Ces composants posent
|
|
7
|
+
// `aria-hidden` eux-mêmes — ne pas y mettre de texte à lire.
|
|
8
|
+
//
|
|
9
|
+
// ⚠️ Tous supposent un parent `position: relative; overflow: hidden`. Sans le
|
|
10
|
+
// second, un halo de 1400px de large fait déborder la page.
|
|
11
|
+
|
|
12
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
13
|
+
import { cx } from './cx.js'
|
|
14
|
+
|
|
15
|
+
type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
|
|
16
|
+
|
|
17
|
+
/* ─── Halo ambiant ─────────────────────────────────────────────────── */
|
|
18
|
+
|
|
19
|
+
/** Le violet et le cyan du spectre, très dilués. Ce n'est PAS un emploi du
|
|
20
|
+
* spectre en aplat : c'est une lumière — elle ne compte pas dans les trois
|
|
21
|
+
* emplois autorisés. */
|
|
22
|
+
export function Halo({
|
|
23
|
+
place,
|
|
24
|
+
className,
|
|
25
|
+
...reste
|
|
26
|
+
}: Div & { place: 'hero' | 'cta' | 'core'; className?: string }) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
aria-hidden="true"
|
|
30
|
+
className={cx('zv-halo', `zv-halo--${place}`, className)}
|
|
31
|
+
{...reste}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ─── Sol quadrillé ────────────────────────────────────────────────── */
|
|
37
|
+
|
|
38
|
+
export function GridFloor({
|
|
39
|
+
carte,
|
|
40
|
+
className,
|
|
41
|
+
...reste
|
|
42
|
+
}: Div & { carte?: boolean; className?: string }) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
aria-hidden="true"
|
|
46
|
+
className={cx('zv-grid-floor', carte && 'zv-grid-floor--card', className)}
|
|
47
|
+
{...reste}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ─── Voile de fondu ───────────────────────────────────────────────── */
|
|
53
|
+
|
|
54
|
+
/** Éteint le bas d'une scène pour que le texte qui la suit reste lisible.
|
|
55
|
+
* `blanc` quand la page est blanche sous la scène. */
|
|
56
|
+
export function Fade({
|
|
57
|
+
blanc,
|
|
58
|
+
className,
|
|
59
|
+
...reste
|
|
60
|
+
}: Div & { blanc?: boolean; className?: string }) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
className={cx('zv-fade', blanc && 'zv-fade--white', className)}
|
|
65
|
+
{...reste}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ─── Bandeau manifeste ────────────────────────────────────────────── */
|
|
71
|
+
|
|
72
|
+
export interface BandProps extends Div {
|
|
73
|
+
/** Glyphe en filigrane, très dilué, dans un coin. */
|
|
74
|
+
filigrane?: ReactNode
|
|
75
|
+
/** Lueur d'accent derrière le contenu. */
|
|
76
|
+
lueur?: boolean
|
|
77
|
+
className?: string
|
|
78
|
+
children?: ReactNode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** LA surface sombre du système. ⚠️ Les surfaces sombres se font avec elle et
|
|
82
|
+
* `--accent-on-dark`, JAMAIS en inversant les neutres : les contrastes ont
|
|
83
|
+
* été réglés dans ce sens-là et pas dans l'autre. */
|
|
84
|
+
export function Band({ filigrane, lueur, className, children, ...reste }: BandProps) {
|
|
85
|
+
return (
|
|
86
|
+
<section className={cx('zv-band', className)} {...reste}>
|
|
87
|
+
{lueur && <div className="zv-band__glow" aria-hidden="true" />}
|
|
88
|
+
{filigrane && (
|
|
89
|
+
<div className="zv-band__watermark" aria-hidden="true">
|
|
90
|
+
{filigrane}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
<div className="zv-band__content">{children}</div>
|
|
94
|
+
</section>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Panneau sombre plus petit qu'une bande : une carte, un encart. */
|
|
99
|
+
export function PanelNavy({ className, ...reste }: Div & { className?: string }) {
|
|
100
|
+
return <div className={cx('zv-panel-navy', className)} {...reste} />
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Repeint un texte pour une surface sombre. `Band` le fait déjà pour son
|
|
104
|
+
* contenu courant — cette classe sert aux cas isolés. */
|
|
105
|
+
export function TextOnDark({ className, ...reste }: Div & { className?: string }) {
|
|
106
|
+
return <div className={cx('zv-text-on-dark', className)} {...reste} />
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ─── Glyphes ──────────────────────────────────────────────────────── */
|
|
110
|
+
|
|
111
|
+
export interface GlyphProps extends Div {
|
|
112
|
+
/** `deep` accentue l'ombre portée, `watermark` dilue le glyphe jusqu'au
|
|
113
|
+
* filigrane. */
|
|
114
|
+
variante?: 'deep' | 'watermark'
|
|
115
|
+
className?: string
|
|
116
|
+
children?: ReactNode
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Enveloppe d'un logo de produit posé comme motif. Le glyphe lui-même est
|
|
120
|
+
* une image du paquet (`@zevra/ui/logos/…`), pas un tracé du CSS. */
|
|
121
|
+
export function Glyph({ variante, className, children, ...reste }: GlyphProps) {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
className={cx('zv-glyph', variante && `zv-glyph--${variante}`, className)}
|
|
126
|
+
{...reste}
|
|
127
|
+
>
|
|
128
|
+
{children}
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ─── Mouvement ────────────────────────────────────────────────────── */
|
|
134
|
+
|
|
135
|
+
export type Animation =
|
|
136
|
+
| 'rise'
|
|
137
|
+
| 'pop'
|
|
138
|
+
| 'filet'
|
|
139
|
+
| 'glyph-in'
|
|
140
|
+
| 'converge'
|
|
141
|
+
| 'breathe'
|
|
142
|
+
| 'drift'
|
|
143
|
+
| 'dot-active'
|
|
144
|
+
| 'progress'
|
|
145
|
+
|
|
146
|
+
/** Les neuf animations du paquet, nommées. ⚠️ Elles sont pilotées AU
|
|
147
|
+
* DÉFILEMENT et réservées à la vitrine : le chrome applicatif n'anime rien.
|
|
148
|
+
* `prefers-reduced-motion` les coupe toutes, globalement, depuis base.css.
|
|
149
|
+
*
|
|
150
|
+
* `option` échelonne les entrées d'une même rangée (`pop`, rangs 2 à 6) ou
|
|
151
|
+
* donne le coin d'arrivée (`converge` : tr, bl, br — le défaut vient du
|
|
152
|
+
* haut-gauche). Passée à une autre animation, elle est ignorée. */
|
|
153
|
+
export function anim(
|
|
154
|
+
nom: Animation,
|
|
155
|
+
option?: 2 | 3 | 4 | 5 | 6 | 'tr' | 'bl' | 'br',
|
|
156
|
+
): string {
|
|
157
|
+
if (nom === 'pop' && typeof option === 'number') return `zv-anim-pop zv-anim-pop--${option}`
|
|
158
|
+
if (nom === 'converge' && typeof option === 'string')
|
|
159
|
+
return `zv-anim-converge zv-anim-converge--${option}`
|
|
160
|
+
return `zv-anim-${nom}`
|
|
161
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Typographie — titres, corps, kickers, labels mono, filets.
|
|
2
|
+
//
|
|
3
|
+
// La HIÉRARCHIE VISUELLE et le NIVEAU SÉMANTIQUE sont deux choses : une page
|
|
4
|
+
// n'a qu'un <h1>, mais elle peut avoir plusieurs blocs à la taille du h1.
|
|
5
|
+
// D'où `as` sur les titres — la taille est un choix de mise en page, la
|
|
6
|
+
// balise un choix de structure. Les confondre casse le plan du document pour
|
|
7
|
+
// un lecteur d'écran sans que rien ne se voie à l'écran.
|
|
8
|
+
|
|
9
|
+
import type { ElementType, HTMLAttributes, ReactNode } from 'react'
|
|
10
|
+
import { cx } from './cx.js'
|
|
11
|
+
|
|
12
|
+
type Div = Omit<HTMLAttributes<HTMLElement>, 'className'>
|
|
13
|
+
type Span = Omit<HTMLAttributes<HTMLSpanElement>, 'className'>
|
|
14
|
+
|
|
15
|
+
/* ─── Titres ───────────────────────────────────────────────────────── */
|
|
16
|
+
|
|
17
|
+
export type TitreTaille = 'h1' | 'h2' | 'h2-sm' | 'h2-cta' | 'h3' | 'carte'
|
|
18
|
+
|
|
19
|
+
const CLASSE_TITRE: Record<TitreTaille, string> = {
|
|
20
|
+
h1: 'zv-h1',
|
|
21
|
+
h2: 'zv-h2',
|
|
22
|
+
'h2-sm': 'zv-h2 zv-h2--sm',
|
|
23
|
+
'h2-cta': 'zv-h2 zv-h2--cta',
|
|
24
|
+
h3: 'zv-h3',
|
|
25
|
+
carte: 'zv-card-title',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HeadingProps extends Div {
|
|
29
|
+
taille?: TitreTaille
|
|
30
|
+
/** Balise réellement rendue. Par défaut, celle qui correspond à la taille. */
|
|
31
|
+
as?: ElementType
|
|
32
|
+
className?: string
|
|
33
|
+
children?: ReactNode
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BALISE_PAR_DEFAUT: Record<TitreTaille, ElementType> = {
|
|
37
|
+
h1: 'h1',
|
|
38
|
+
h2: 'h2',
|
|
39
|
+
'h2-sm': 'h2',
|
|
40
|
+
'h2-cta': 'h2',
|
|
41
|
+
h3: 'h3',
|
|
42
|
+
carte: 'h3',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function Heading({ taille = 'h2', as, className, ...reste }: HeadingProps) {
|
|
46
|
+
const Balise = as ?? BALISE_PAR_DEFAUT[taille]
|
|
47
|
+
return <Balise className={cx(CLASSE_TITRE[taille], className)} {...reste} />
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const H1 = (p: Omit<HeadingProps, 'taille'>) => <Heading taille="h1" {...p} />
|
|
51
|
+
export const H2 = (p: Omit<HeadingProps, 'taille'>) => <Heading taille="h2" {...p} />
|
|
52
|
+
export const H3 = (p: Omit<HeadingProps, 'taille'>) => <Heading taille="h3" {...p} />
|
|
53
|
+
export const CardTitle = (p: Omit<HeadingProps, 'taille'>) => (
|
|
54
|
+
<Heading taille="carte" {...p} />
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
/** Chiffre ou mot posé pour sa PRÉSENCE, hors de la hiérarchie des titres —
|
|
58
|
+
* d'où le `<div>` : un chiffre géant annoncé comme titre de section pollue
|
|
59
|
+
* le plan du document. */
|
|
60
|
+
export function Display({ className, ...reste }: Div & { className?: string }) {
|
|
61
|
+
return <div className={cx('zv-display', className)} {...reste} />
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ─── Corps ────────────────────────────────────────────────────────── */
|
|
65
|
+
|
|
66
|
+
export interface BodyProps extends Div {
|
|
67
|
+
taille?: 'normal' | 'lg' | 'dense'
|
|
68
|
+
as?: ElementType
|
|
69
|
+
className?: string
|
|
70
|
+
children?: ReactNode
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function Body({ taille = 'normal', as, className, ...reste }: BodyProps) {
|
|
74
|
+
const Balise = as ?? 'p'
|
|
75
|
+
return (
|
|
76
|
+
<Balise
|
|
77
|
+
className={cx(
|
|
78
|
+
'zv-body',
|
|
79
|
+
taille === 'lg' && 'zv-body--lg',
|
|
80
|
+
taille === 'dense' && 'zv-body--dense',
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
{...reste}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Chapô : le paragraphe qui suit immédiatement un titre de section. */
|
|
89
|
+
export function Lead({
|
|
90
|
+
as,
|
|
91
|
+
className,
|
|
92
|
+
...reste
|
|
93
|
+
}: Div & { as?: ElementType; className?: string }) {
|
|
94
|
+
const Balise = as ?? 'p'
|
|
95
|
+
return <Balise className={cx('zv-lead', className)} {...reste} />
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Mention légale, précision de bas de bloc. */
|
|
99
|
+
export function Note({
|
|
100
|
+
as,
|
|
101
|
+
className,
|
|
102
|
+
...reste
|
|
103
|
+
}: Div & { as?: ElementType; className?: string }) {
|
|
104
|
+
const Balise = as ?? 'p'
|
|
105
|
+
return <Balise className={cx('zv-note', className)} {...reste} />
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Signature de marque, sous un logo. */
|
|
109
|
+
export function Tagline({ className, ...reste }: Span & { className?: string }) {
|
|
110
|
+
return <span className={cx('zv-tagline', className)} {...reste} />
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Mise en valeur dans le fil du texte. Le pendant `surSombre` existe parce
|
|
114
|
+
* que l'indigo d'accent devient illisible sur la bande manifeste. */
|
|
115
|
+
export function Em({
|
|
116
|
+
surSombre,
|
|
117
|
+
className,
|
|
118
|
+
...reste
|
|
119
|
+
}: Span & { surSombre?: boolean; className?: string }) {
|
|
120
|
+
return (
|
|
121
|
+
<em className={cx('zv-em', surSombre && 'zv-em--on-dark', className)} {...reste} />
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function Link({
|
|
126
|
+
className,
|
|
127
|
+
...reste
|
|
128
|
+
}: Omit<HTMLAttributes<HTMLAnchorElement>, 'className'> & {
|
|
129
|
+
className?: string
|
|
130
|
+
href?: string
|
|
131
|
+
}) {
|
|
132
|
+
return <a className={cx('zv-link', className)} {...reste} />
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ─── Kicker et filet de signature ─────────────────────────────────── */
|
|
136
|
+
|
|
137
|
+
export interface KickerProps extends Div {
|
|
138
|
+
/** `center` centre le bloc, `stack` empile le filet AU-DESSUS du texte au
|
|
139
|
+
* lieu de le poser devant. */
|
|
140
|
+
variante?: 'center' | 'stack'
|
|
141
|
+
/** Le filet de spectre 56×2 devant le kicker. Premier des trois emplois
|
|
142
|
+
* autorisés du spectre. */
|
|
143
|
+
filet?: boolean
|
|
144
|
+
surSombre?: boolean
|
|
145
|
+
className?: string
|
|
146
|
+
children?: ReactNode
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function Kicker({
|
|
150
|
+
variante,
|
|
151
|
+
filet = true,
|
|
152
|
+
surSombre,
|
|
153
|
+
className,
|
|
154
|
+
children,
|
|
155
|
+
...reste
|
|
156
|
+
}: KickerProps) {
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
className={cx('zv-kicker', variante && `zv-kicker--${variante}`, className)}
|
|
160
|
+
{...reste}
|
|
161
|
+
>
|
|
162
|
+
{filet && <Spec surSombre={surSombre} />}
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Le filet de spectre seul. Décoratif : masqué aux lecteurs d'écran. */
|
|
169
|
+
export function Spec({
|
|
170
|
+
petit,
|
|
171
|
+
surSombre,
|
|
172
|
+
className,
|
|
173
|
+
...reste
|
|
174
|
+
}: Span & { petit?: boolean; surSombre?: boolean; className?: string }) {
|
|
175
|
+
return (
|
|
176
|
+
<span
|
|
177
|
+
aria-hidden="true"
|
|
178
|
+
className={cx(
|
|
179
|
+
'zv-spec',
|
|
180
|
+
petit && 'zv-spec--sm',
|
|
181
|
+
surSombre && 'zv-spec--on-dark',
|
|
182
|
+
className,
|
|
183
|
+
)}
|
|
184
|
+
{...reste}
|
|
185
|
+
/>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Filet neutre de séparation de section. */
|
|
190
|
+
export function Rule({ className, ...reste }: Div & { className?: string }) {
|
|
191
|
+
return <div className={cx('zv-rule', className)} {...reste} />
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ─── Label mono ───────────────────────────────────────────────────── */
|
|
195
|
+
|
|
196
|
+
export interface MonoProps extends Span {
|
|
197
|
+
/** Le ton, pas la taille : `label` est le gris d'intitulé, `ink` l'encre
|
|
198
|
+
* pleine, `muted` le gris secondaire, `on-dark` la bande manifeste. */
|
|
199
|
+
ton?: 'label' | 'ink' | 'muted' | 'on-dark'
|
|
200
|
+
/** Le plus petit palier — badge dense, compteur de coque. */
|
|
201
|
+
micro?: boolean
|
|
202
|
+
className?: string
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Capitales espacées. ⚠️ Réservé aux LIBELLÉS : une date ou un nom de
|
|
206
|
+
* fichier passés en capitales espacées deviennent illisibles et s'alignent
|
|
207
|
+
* mal — pour ceux-là, c'est `Meta` (bas de casse, chasse tabulaire). */
|
|
208
|
+
export function Mono({ ton, micro, className, ...reste }: MonoProps) {
|
|
209
|
+
return (
|
|
210
|
+
<span
|
|
211
|
+
className={cx(
|
|
212
|
+
'zv-mono',
|
|
213
|
+
ton && `zv-mono--${ton}`,
|
|
214
|
+
micro && 'zv-mono--micro',
|
|
215
|
+
className,
|
|
216
|
+
)}
|
|
217
|
+
{...reste}
|
|
218
|
+
/>
|
|
219
|
+
)
|
|
220
|
+
}
|