@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
package/react/layout.tsx
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Mise en page vitrine — page, gouttières, grille 12, sections, hero, frise
|
|
2
|
+
// d'étapes, statistiques, pieds de page, couches d'empilement.
|
|
3
|
+
//
|
|
4
|
+
// Couche VITRINE : une application n'a aucune raison de charger ce fichier.
|
|
5
|
+
// Tout y est sans état.
|
|
6
|
+
|
|
7
|
+
import type { ElementType, HTMLAttributes, ReactNode } from 'react'
|
|
8
|
+
import { cx } from './cx.js'
|
|
9
|
+
|
|
10
|
+
type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
|
|
11
|
+
|
|
12
|
+
/* ─── Page et gouttières ───────────────────────────────────────────── */
|
|
13
|
+
|
|
14
|
+
export function Page({
|
|
15
|
+
as,
|
|
16
|
+
className,
|
|
17
|
+
...reste
|
|
18
|
+
}: Div & { as?: ElementType; className?: string }) {
|
|
19
|
+
const Balise = as ?? 'div'
|
|
20
|
+
return <Balise className={cx('zv-page', className)} {...reste} />
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GutterProps extends Div {
|
|
24
|
+
/** `wide` pour une bande pleine largeur, `chrome` pour la barre de
|
|
25
|
+
* navigation — qui ne s'aligne pas sur la colonne de texte. */
|
|
26
|
+
variante?: 'wide' | 'chrome'
|
|
27
|
+
as?: ElementType
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** La largeur de colonne du système. Toute la vitrine s'aligne dessus : un
|
|
32
|
+
* bloc qui pose sa propre largeur maximale se décale de tous les autres. */
|
|
33
|
+
export function Gutter({ variante, as, className, ...reste }: GutterProps) {
|
|
34
|
+
const Balise = as ?? 'div'
|
|
35
|
+
return (
|
|
36
|
+
<Balise
|
|
37
|
+
className={cx('zv-gutter', variante && `zv-gutter--${variante}`, className)}
|
|
38
|
+
{...reste}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Débord latéral : ressort de la gouttière pour aller au bord de l'écran
|
|
44
|
+
* (une bande de couleur, une image pleine largeur). */
|
|
45
|
+
export function InsetX({ className, ...reste }: Div & { className?: string }) {
|
|
46
|
+
return <div className={cx('zv-inset-x', className)} {...reste} />
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ─── Grilles ──────────────────────────────────────────────────────── */
|
|
50
|
+
|
|
51
|
+
export function Grid12({ className, ...reste }: Div & { className?: string }) {
|
|
52
|
+
return <div className={cx('zv-grid-12', className)} {...reste} />
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type Span12 = 3 | 4 | 6 | 8 | 9
|
|
56
|
+
|
|
57
|
+
/** ⚠️ Seules 3, 4, 6, 8 et 9 existent : ce sont les découpes employées par la
|
|
58
|
+
* vitrine. En ajouter une revient à ajouter une mise en page, pas une
|
|
59
|
+
* utilité — c'est une décision de DA. */
|
|
60
|
+
export function Col({
|
|
61
|
+
span,
|
|
62
|
+
className,
|
|
63
|
+
...reste
|
|
64
|
+
}: Div & { span: Span12; className?: string }) {
|
|
65
|
+
return <div className={cx(`zv-col-${span}`, className)} {...reste} />
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Grilles régulières, empilées sous le point de rupture. */
|
|
69
|
+
export function Grid({
|
|
70
|
+
colonnes,
|
|
71
|
+
className,
|
|
72
|
+
...reste
|
|
73
|
+
}: Div & { colonnes: 3 | 4; className?: string }) {
|
|
74
|
+
return <div className={cx(`zv-grid-${colonnes}`, className)} {...reste} />
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ─── Sections ─────────────────────────────────────────────────────── */
|
|
78
|
+
|
|
79
|
+
export interface SectionHeadProps extends Div {
|
|
80
|
+
/** Bloc de droite : un lien, un chiffre, une précision. Il tombe SOUS le
|
|
81
|
+
* titre en dessous du point de rupture. */
|
|
82
|
+
aside?: ReactNode
|
|
83
|
+
className?: string
|
|
84
|
+
children?: ReactNode
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function SectionHead({ aside, className, children, ...reste }: SectionHeadProps) {
|
|
88
|
+
return (
|
|
89
|
+
<div className={cx('zv-section-head', className)} {...reste}>
|
|
90
|
+
<div className="zv-section-head__main">{children}</div>
|
|
91
|
+
{aside && <div className="zv-section-head__aside">{aside}</div>}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function SectionRule({
|
|
97
|
+
discret,
|
|
98
|
+
className,
|
|
99
|
+
...reste
|
|
100
|
+
}: Div & { discret?: boolean; className?: string }) {
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
className={cx('zv-section-rule', discret && 'zv-section-rule--quiet', className)}
|
|
104
|
+
{...reste}
|
|
105
|
+
/>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SheetSectionProps extends Div {
|
|
110
|
+
titre?: ReactNode
|
|
111
|
+
className?: string
|
|
112
|
+
children?: ReactNode
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Section d'un document long : un intitulé mono, un filet, du corps. */
|
|
116
|
+
export function SheetSection({ titre, className, children, ...reste }: SheetSectionProps) {
|
|
117
|
+
return (
|
|
118
|
+
<section className={cx('zv-sheet-section', className)} {...reste}>
|
|
119
|
+
{titre && <h2 className="zv-sheet-section__title">{titre}</h2>}
|
|
120
|
+
<div className="zv-sheet-section__body">{children}</div>
|
|
121
|
+
</section>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ─── Blocs centrés ────────────────────────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
export function Hero({ className, ...reste }: Div & { className?: string }) {
|
|
128
|
+
return <section className={cx('zv-hero', className)} {...reste} />
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function CTA({ className, ...reste }: Div & { className?: string }) {
|
|
132
|
+
return <section className={cx('zv-cta', className)} {...reste} />
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** En-tête fixe de la vitrine. */
|
|
136
|
+
export function Header({ className, ...reste }: Div & { className?: string }) {
|
|
137
|
+
return <header className={cx('zv-header', className)} {...reste} />
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ─── Navigation vitrine ───────────────────────────────────────────── */
|
|
141
|
+
|
|
142
|
+
export function Nav({ className, ...reste }: Div & { className?: string }) {
|
|
143
|
+
return <nav className={cx('zv-nav', className)} {...reste} />
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function NavLogo({ className, ...reste }: Div & { className?: string }) {
|
|
147
|
+
return <div className={cx('zv-nav__logo', className)} {...reste} />
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** ⚠️ Cette rangée disparaît sous le point de rupture, remplacée par le
|
|
151
|
+
* burger. Le CSS s'en occupe : ne jamais la conditionner en JavaScript. */
|
|
152
|
+
export function NavLinks({ className, ...reste }: Div & { className?: string }) {
|
|
153
|
+
return <div className={cx('zv-nav__links', className)} {...reste} />
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface NavLinkProps
|
|
157
|
+
extends Omit<HTMLAttributes<HTMLAnchorElement>, 'className'> {
|
|
158
|
+
/** Lien secondaire : connexion, mentions. */
|
|
159
|
+
discret?: boolean
|
|
160
|
+
href?: string
|
|
161
|
+
className?: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** ⚠️ `.zv-navlink` est la navigation de VITRINE. L'entrée applicative
|
|
165
|
+
* s'appelle `SideLink` — les deux ne doivent jamais se recouvrir. */
|
|
166
|
+
export function NavLink({ discret, className, ...reste }: NavLinkProps) {
|
|
167
|
+
return (
|
|
168
|
+
<a className={cx('zv-navlink', discret && 'zv-navlink--quiet', className)} {...reste} />
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Le burger de la vitrine. Il n'apparaît que sous le point de rupture. */
|
|
173
|
+
export function NavBurger({
|
|
174
|
+
className,
|
|
175
|
+
type = 'button',
|
|
176
|
+
...reste
|
|
177
|
+
}: Omit<HTMLAttributes<HTMLButtonElement>, 'className'> & {
|
|
178
|
+
className?: string
|
|
179
|
+
type?: 'button'
|
|
180
|
+
'aria-label': string
|
|
181
|
+
}) {
|
|
182
|
+
return <button type={type} className={cx('zv-nav__burger', className)} {...reste} />
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Le contenu du tiroir de navigation vitrine — à poser dans `Drawer`. */
|
|
186
|
+
export function NavDrawer({ className, ...reste }: Div & { className?: string }) {
|
|
187
|
+
return <div className={cx('zv-nav-drawer', className)} {...reste} />
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function NavDrawerLink({
|
|
191
|
+
courant,
|
|
192
|
+
className,
|
|
193
|
+
...reste
|
|
194
|
+
}: Omit<HTMLAttributes<HTMLAnchorElement>, 'className'> & {
|
|
195
|
+
courant?: boolean
|
|
196
|
+
href?: string
|
|
197
|
+
className?: string
|
|
198
|
+
}) {
|
|
199
|
+
return (
|
|
200
|
+
<a
|
|
201
|
+
aria-current={courant ? 'page' : undefined}
|
|
202
|
+
className={cx('zv-nav-drawer__link', courant && 'zv-nav-drawer__link--on', className)}
|
|
203
|
+
{...reste}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ─── Frise d'étapes ───────────────────────────────────────────────── */
|
|
209
|
+
|
|
210
|
+
export interface StepsProps extends Div {
|
|
211
|
+
/** Avancement du rail, entre 0 et 1 — piloté au défilement. */
|
|
212
|
+
avancement?: number
|
|
213
|
+
className?: string
|
|
214
|
+
children?: ReactNode
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function Steps({ avancement, className, children, ...reste }: StepsProps) {
|
|
218
|
+
const part = Math.min(1, Math.max(0, avancement ?? 0))
|
|
219
|
+
return (
|
|
220
|
+
<div className={cx('zv-steps', className)} {...reste}>
|
|
221
|
+
<div className="zv-steps__rail" aria-hidden="true">
|
|
222
|
+
<div className="zv-steps__progress" style={{ width: `${part * 100}%` }} />
|
|
223
|
+
</div>
|
|
224
|
+
{children}
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface StepProps extends Div {
|
|
230
|
+
/** Troisième et dernier emploi autorisé du spectre : le point de méthode.
|
|
231
|
+
* Jamais en fond, jamais en texte. */
|
|
232
|
+
teinte?: 'violet' | 'cyan'
|
|
233
|
+
className?: string
|
|
234
|
+
children?: ReactNode
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function Step({ teinte, className, children, ...reste }: StepProps) {
|
|
238
|
+
return (
|
|
239
|
+
<div className={cx('zv-step', className)} {...reste}>
|
|
240
|
+
<span
|
|
241
|
+
className={cx('zv-step__dot', teinte && `zv-step__dot--${teinte}`)}
|
|
242
|
+
aria-hidden="true"
|
|
243
|
+
/>
|
|
244
|
+
{children}
|
|
245
|
+
</div>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ─── Statistiques ─────────────────────────────────────────────────── */
|
|
250
|
+
|
|
251
|
+
export function Stats({ className, ...reste }: Div & { className?: string }) {
|
|
252
|
+
return <div className={cx('zv-stats', className)} {...reste} />
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface StatProps extends Div {
|
|
256
|
+
valeur: ReactNode
|
|
257
|
+
className?: string
|
|
258
|
+
children?: ReactNode
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function Stat({ valeur, className, children, ...reste }: StatProps) {
|
|
262
|
+
return (
|
|
263
|
+
<div className={cx('zv-stat', className)} {...reste}>
|
|
264
|
+
<span className="zv-stat__value">{valeur}</span>
|
|
265
|
+
{children}
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ─── Pieds de page ────────────────────────────────────────────────── */
|
|
271
|
+
|
|
272
|
+
export interface FooterProps extends Div {
|
|
273
|
+
/** Colonne de gauche : logo, signature, mention légale. */
|
|
274
|
+
marque?: ReactNode
|
|
275
|
+
className?: string
|
|
276
|
+
children?: ReactNode
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function Footer({ marque, className, children, ...reste }: FooterProps) {
|
|
280
|
+
return (
|
|
281
|
+
<footer className={cx('zv-footer', className)} {...reste}>
|
|
282
|
+
{marque && <div className="zv-footer__brand">{marque}</div>}
|
|
283
|
+
<div className="zv-footer__cols">{children}</div>
|
|
284
|
+
</footer>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Pied réduit : une ligne, un logo, des mentions. Pour une page isolée
|
|
289
|
+
* (connexion, document partagé) qui n'a pas de pied complet à porter. */
|
|
290
|
+
export function FooterMini({
|
|
291
|
+
logo,
|
|
292
|
+
className,
|
|
293
|
+
children,
|
|
294
|
+
...reste
|
|
295
|
+
}: Div & { logo?: ReactNode; className?: string; children?: ReactNode }) {
|
|
296
|
+
return (
|
|
297
|
+
<footer className={cx('zv-footer-mini', className)} {...reste}>
|
|
298
|
+
{logo && <div className="zv-footer-mini__logo">{logo}</div>}
|
|
299
|
+
{children}
|
|
300
|
+
</footer>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface ProofBarProps extends Div {
|
|
305
|
+
/** Les noms cités. ⚠️ RÈGLE DE CHARTE : jamais de client inventé. */
|
|
306
|
+
noms?: ReactNode
|
|
307
|
+
className?: string
|
|
308
|
+
children?: ReactNode
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function ProofBar({ noms, className, children, ...reste }: ProofBarProps) {
|
|
312
|
+
return (
|
|
313
|
+
<div className={cx('zv-proof-bar', className)} {...reste}>
|
|
314
|
+
{children}
|
|
315
|
+
{noms && <div className="zv-proof-bar__names">{noms}</div>}
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ─── Empilement ───────────────────────────────────────────────────── */
|
|
321
|
+
|
|
322
|
+
/** Les cinq étages du système, nommés une fois pour toutes : un `z-index`
|
|
323
|
+
* écrit à la main quelque part finit toujours par passer devant ou derrière
|
|
324
|
+
* ce qu'il ne fallait pas. Les enrobages (voile 60 → notification 100) sont
|
|
325
|
+
* au-dessus, dans overlays.css. */
|
|
326
|
+
export type Etage = 'scene' | 'content' | 'progress' | 'chrome' | 'top'
|
|
327
|
+
|
|
328
|
+
export function Layer({
|
|
329
|
+
etage,
|
|
330
|
+
className,
|
|
331
|
+
...reste
|
|
332
|
+
}: Div & { etage: Etage; className?: string }) {
|
|
333
|
+
return <div className={cx(`zv-layer-${etage}`, className)} {...reste} />
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* ─── Visibilité ───────────────────────────────────────────────────── */
|
|
337
|
+
|
|
338
|
+
/** ⚠️ Rendu par le CSS, PAS par JavaScript : le serveur ne connaît pas la
|
|
339
|
+
* largeur de l'écran, et conditionner le rendu produit un décalage
|
|
340
|
+
* d'hydratation. Les deux nœuds sont posés, le CSS en masque un. */
|
|
341
|
+
export function OnlyDesktop({ className, ...reste }: Div & { className?: string }) {
|
|
342
|
+
return <div className={cx('zv-only-desktop', className)} {...reste} />
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function OnlyMobile({ className, ...reste }: Div & { className?: string }) {
|
|
346
|
+
return <div className={cx('zv-only-mobile', className)} {...reste} />
|
|
347
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// Navigation applicative — colonne latérale, barre supérieure, fil d'Ariane,
|
|
2
|
+
// menu, pagination, feuille de source, zones défilantes.
|
|
3
|
+
//
|
|
4
|
+
// Rien ici n'a d'état propre : l'entrée courante, la page courante, l'item
|
|
5
|
+
// sélectionné viennent de l'appelant. Aucun « use client », donc — c'est ce
|
|
6
|
+
// qui permet à une colonne de navigation Next de rester rendue au serveur.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ Les OVERLAYS (tiroir, modale, notification) sont dans overlays.tsx : eux
|
|
9
|
+
// ont besoin du navigateur.
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
AnchorHTMLAttributes,
|
|
13
|
+
ButtonHTMLAttributes,
|
|
14
|
+
HTMLAttributes,
|
|
15
|
+
ReactNode,
|
|
16
|
+
} from 'react'
|
|
17
|
+
import { cx } from './cx.js'
|
|
18
|
+
import { Seg, SegGroup } from './forms.js'
|
|
19
|
+
|
|
20
|
+
type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
|
|
21
|
+
type Span = Omit<HTMLAttributes<HTMLSpanElement>, 'className'>
|
|
22
|
+
type A = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'className'>
|
|
23
|
+
type Btn = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'>
|
|
24
|
+
|
|
25
|
+
/* ─── Colonne latérale ─────────────────────────────────────────────── */
|
|
26
|
+
|
|
27
|
+
/** Une colonne, un filet, rien d'autre : ni ombre, ni rayon. Une ombre ferait
|
|
28
|
+
* flotter une pièce qui ne bouge jamais. Elle défile pour son propre compte —
|
|
29
|
+
* une nav longue n'entraîne jamais le contenu. */
|
|
30
|
+
export function Sidebar({ className, ...reste }: Div & { className?: string }) {
|
|
31
|
+
return <aside className={cx('zv-sidebar', className)} {...reste} />
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function SidebarHead({ className, ...reste }: Div & { className?: string }) {
|
|
35
|
+
return <div className={cx('zv-sidebar__head', className)} {...reste} />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Collé en bas quand la nav est courte, il suit le contenu quand elle est
|
|
39
|
+
* longue. */
|
|
40
|
+
export function SidebarFoot({ className, ...reste }: Div & { className?: string }) {
|
|
41
|
+
return <div className={cx('zv-sidebar__foot', className)} {...reste} />
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SideGroupProps extends Div {
|
|
45
|
+
titre?: ReactNode
|
|
46
|
+
className?: string
|
|
47
|
+
children?: ReactNode
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function SideGroup({ titre, className, children, ...reste }: SideGroupProps) {
|
|
51
|
+
return (
|
|
52
|
+
<div className={cx('zv-sidegroup', className)} {...reste}>
|
|
53
|
+
{titre && <div className="zv-sidegroup__title">{titre}</div>}
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SideLinkProps extends A {
|
|
60
|
+
/** Pose `aria-current="page"` en plus de la classe : sans lui, l'entrée
|
|
61
|
+
* courante est marquée pour l'œil et muette au lecteur d'écran. */
|
|
62
|
+
courant?: boolean
|
|
63
|
+
/** Rang mono ou glyphe, sur une colonne de largeur fixe — sinon les
|
|
64
|
+
* libellés se décalent d'un groupe à l'autre. */
|
|
65
|
+
num?: ReactNode
|
|
66
|
+
className?: string
|
|
67
|
+
children?: ReactNode
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function SideLink({ courant, num, className, children, ...reste }: SideLinkProps) {
|
|
71
|
+
return (
|
|
72
|
+
<a
|
|
73
|
+
aria-current={courant ? 'page' : undefined}
|
|
74
|
+
className={cx('zv-sidelink', courant && 'zv-sidelink--on', className)}
|
|
75
|
+
{...reste}
|
|
76
|
+
>
|
|
77
|
+
{num != null && <span className="zv-sidelink__num">{num}</span>}
|
|
78
|
+
{children}
|
|
79
|
+
</a>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* ─── Barre supérieure ─────────────────────────────────────────────── */
|
|
84
|
+
|
|
85
|
+
/** Elle REMPLACE la colonne sous le point de rupture : masquée au-dessus,
|
|
86
|
+
* elle n'apparaît que dans le bloc mobile du CSS. Ne pas la conditionner en
|
|
87
|
+
* JavaScript — le rendu serveur ne connaît pas la largeur de l'écran. */
|
|
88
|
+
export function Topbar({ className, ...reste }: Div & { className?: string }) {
|
|
89
|
+
return <header className={cx('zv-topbar', className)} {...reste} />
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Le logo cède AVANT le nom : un logo client très large ne doit jamais
|
|
93
|
+
* pousser la barre hors de l'écran. */
|
|
94
|
+
export function TopbarLogo({ className, ...reste }: Div & { className?: string }) {
|
|
95
|
+
return <div className={cx('zv-topbar__logo', className)} {...reste} />
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function TopbarTitle({ className, ...reste }: Span & { className?: string }) {
|
|
99
|
+
return <span className={cx('zv-topbar__title', className)} {...reste} />
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function TopbarBurger({ className, type = 'button', ...reste }: Btn & { className?: string }) {
|
|
103
|
+
return <button type={type} className={cx('zv-topbar__burger', className)} {...reste} />
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Croix bordée du chrome — fermeture d'un tiroir, d'une feuille de source.
|
|
107
|
+
* Angles vifs : le rayon signale l'action, le chrome n'en est pas une. */
|
|
108
|
+
export function ChromeButton({
|
|
109
|
+
className,
|
|
110
|
+
type = 'button',
|
|
111
|
+
...reste
|
|
112
|
+
}: Btn & { className?: string; 'aria-label': string }) {
|
|
113
|
+
return <button type={type} className={cx('zv-chrome-btn', className)} {...reste} />
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* ─── Fil d'Ariane ─────────────────────────────────────────────────── */
|
|
117
|
+
|
|
118
|
+
export interface CrumbsProps extends Omit<A, 'children'> {
|
|
119
|
+
/** Les segments, du plus général au plus précis. Le DERNIER est plus sombre :
|
|
120
|
+
* c'est celui qu'on lit, le reste n'est qu'un chemin. */
|
|
121
|
+
segments: ReactNode[]
|
|
122
|
+
/** Séparateur. Le `/` par défaut est celui du relevé. */
|
|
123
|
+
separateur?: ReactNode
|
|
124
|
+
className?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** ⚠️ Le fil ENTIER est cliquable — c'est un seul nœud, pas une liste de
|
|
128
|
+
* liens. Sans `href`, il se rend en `<span>` : un `<a>` sans cible reste
|
|
129
|
+
* annoncé comme un lien et déçoit. */
|
|
130
|
+
export function Crumbs({
|
|
131
|
+
segments,
|
|
132
|
+
separateur = '/',
|
|
133
|
+
href,
|
|
134
|
+
className,
|
|
135
|
+
...reste
|
|
136
|
+
}: CrumbsProps) {
|
|
137
|
+
const contenu = segments.map((seg, i) => (
|
|
138
|
+
<span key={i}>
|
|
139
|
+
{i > 0 && (
|
|
140
|
+
<span className="zv-crumbs__sep" aria-hidden="true">
|
|
141
|
+
{separateur}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
<span className={i === segments.length - 1 ? 'zv-crumbs__seg' : undefined}>{seg}</span>
|
|
145
|
+
</span>
|
|
146
|
+
))
|
|
147
|
+
const classe = cx('zv-crumbs', className)
|
|
148
|
+
if (!href) {
|
|
149
|
+
return <span className={classe}>{contenu}</span>
|
|
150
|
+
}
|
|
151
|
+
return (
|
|
152
|
+
<a href={href} className={classe} {...reste}>
|
|
153
|
+
{contenu}
|
|
154
|
+
</a>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ─── Menu déroulant ───────────────────────────────────────────────── */
|
|
159
|
+
|
|
160
|
+
/** À poser dans un `SelectAnchor` (forms) : c'est l'enveloppe qui ancre le
|
|
161
|
+
* menu, pas le déclencheur — l'ancrer sur le déclencheur casse sa largeur
|
|
162
|
+
* maximale calculée sur la fenêtre. */
|
|
163
|
+
export function Menu({ className, ...reste }: Div & { className?: string; role?: string }) {
|
|
164
|
+
return <div role="listbox" className={cx('zv-menu', className)} {...reste} />
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface MenuItemProps extends Btn {
|
|
168
|
+
/** Pose `aria-selected` en plus de la classe. */
|
|
169
|
+
actif?: boolean
|
|
170
|
+
className?: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function MenuItem({ actif, className, type = 'button', ...reste }: MenuItemProps) {
|
|
174
|
+
return (
|
|
175
|
+
<button
|
|
176
|
+
type={type}
|
|
177
|
+
role="option"
|
|
178
|
+
aria-selected={actif ?? false}
|
|
179
|
+
className={cx('zv-menu__item', actif && 'zv-menu__item--on', className)}
|
|
180
|
+
{...reste}
|
|
181
|
+
/>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ─── Onglets ──────────────────────────────────────────────────────── */
|
|
186
|
+
|
|
187
|
+
export interface TabsProps<T extends string> {
|
|
188
|
+
onglets: readonly { id: T; label: ReactNode; compte?: ReactNode }[]
|
|
189
|
+
actif: T
|
|
190
|
+
onChange: (id: T) => void
|
|
191
|
+
/** Rangée qui défile au lieu de passer à la ligne. */
|
|
192
|
+
rail?: boolean
|
|
193
|
+
/** Identifiant du panneau piloté, pour `aria-controls`. */
|
|
194
|
+
panneau?: string
|
|
195
|
+
className?: string
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** ⚠️ Il n'existe AUCUNE classe d'onglet dans le CSS, et c'est délibéré : une
|
|
199
|
+
* rangée d'onglets est une rangée de puces, avec la grammaire d'état de
|
|
200
|
+
* `Seg`. Un onglet SOULIGNÉ reste à faire — il suppose d'arbitrer couleur et
|
|
201
|
+
* épaisseur du trait actif, ce qu'aucune source ne donne. Ce composant
|
|
202
|
+
* n'ajoute donc que l'ARIA, pas un style de plus. */
|
|
203
|
+
export function Tabs<T extends string>({
|
|
204
|
+
onglets,
|
|
205
|
+
actif,
|
|
206
|
+
onChange,
|
|
207
|
+
rail,
|
|
208
|
+
panneau,
|
|
209
|
+
className,
|
|
210
|
+
}: TabsProps<T>) {
|
|
211
|
+
return (
|
|
212
|
+
<SegGroup role="tablist" rail={rail} className={className}>
|
|
213
|
+
{onglets.map((o) => (
|
|
214
|
+
<Seg
|
|
215
|
+
key={o.id}
|
|
216
|
+
role="tab"
|
|
217
|
+
aria-selected={o.id === actif}
|
|
218
|
+
aria-controls={panneau}
|
|
219
|
+
on={o.id === actif}
|
|
220
|
+
compte={o.compte}
|
|
221
|
+
onClick={() => onChange(o.id)}
|
|
222
|
+
>
|
|
223
|
+
{o.label}
|
|
224
|
+
</Seg>
|
|
225
|
+
))}
|
|
226
|
+
</SegGroup>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ─── Pagination ───────────────────────────────────────────────────── */
|
|
231
|
+
|
|
232
|
+
export interface PagerProps extends Div {
|
|
233
|
+
/** Pied de tableau : compte à gauche, sens à droite. C'est un MODIFICATEUR,
|
|
234
|
+
* il se pose avec `Pager`, jamais seul. */
|
|
235
|
+
pied?: boolean
|
|
236
|
+
className?: string
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function Pager({ pied, className, ...reste }: PagerProps) {
|
|
240
|
+
return (
|
|
241
|
+
<nav
|
|
242
|
+
className={cx('zv-pager', pied && 'zv-pager--foot', className)}
|
|
243
|
+
{...reste}
|
|
244
|
+
/>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** ⚠️ Une flèche désactivée reste ATTEIGNABLE au clavier (`aria-disabled` et
|
|
249
|
+
* non `disabled`) : sinon elle disparaît du parcours sans être annoncée. */
|
|
250
|
+
export function PagerArrow({
|
|
251
|
+
desactive,
|
|
252
|
+
className,
|
|
253
|
+
type = 'button',
|
|
254
|
+
...reste
|
|
255
|
+
}: Btn & { desactive?: boolean; className?: string; 'aria-label': string }) {
|
|
256
|
+
return (
|
|
257
|
+
<button
|
|
258
|
+
type={type}
|
|
259
|
+
aria-disabled={desactive || undefined}
|
|
260
|
+
className={cx('zv-pager__arrow', className)}
|
|
261
|
+
{...reste}
|
|
262
|
+
/>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Le rang courant (« p. 12 / 31 »). Cliquable dans le relevé : il ouvre la
|
|
267
|
+
* saisie directe d'un numéro. */
|
|
268
|
+
export function PagerRank({ className, type = 'button', ...reste }: Btn & { className?: string }) {
|
|
269
|
+
return <button type={type} className={cx('zv-pager__rank', className)} {...reste} />
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** « ‹ Précédent · Suivant › » du pied de tableau. */
|
|
273
|
+
export function PagerStep({ className, type = 'button', ...reste }: Btn & { className?: string }) {
|
|
274
|
+
return <button type={type} className={cx('zv-pager__step', className)} {...reste} />
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* ─── Feuille de source citée ──────────────────────────────────────── */
|
|
278
|
+
|
|
279
|
+
export interface CitePanelProps extends Div {
|
|
280
|
+
titre?: ReactNode
|
|
281
|
+
/** Croix de fermeture et actions de tête. */
|
|
282
|
+
actions?: ReactNode
|
|
283
|
+
pied?: ReactNode
|
|
284
|
+
className?: string
|
|
285
|
+
children?: ReactNode
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function CitePanel({
|
|
289
|
+
titre,
|
|
290
|
+
actions,
|
|
291
|
+
pied,
|
|
292
|
+
className,
|
|
293
|
+
children,
|
|
294
|
+
...reste
|
|
295
|
+
}: CitePanelProps) {
|
|
296
|
+
return (
|
|
297
|
+
<div className={cx('zv-cite-panel', className)} {...reste}>
|
|
298
|
+
<div className="zv-cite-panel__head">
|
|
299
|
+
{titre && <span className="zv-cite-panel__title">{titre}</span>}
|
|
300
|
+
{actions}
|
|
301
|
+
</div>
|
|
302
|
+
<div className="zv-cite-panel__body">{children}</div>
|
|
303
|
+
{pied && <div className="zv-cite-panel__foot">{pied}</div>}
|
|
304
|
+
</div>
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* ─── Défilement ───────────────────────────────────────────────────── */
|
|
309
|
+
|
|
310
|
+
export interface ScrollProps extends Div {
|
|
311
|
+
/** Barre fine, aux couleurs du système. OPT-IN : par défaut la barre native
|
|
312
|
+
* est conservée — sur macOS et iOS, l'habiller la rend permanente et elle
|
|
313
|
+
* mange une colonne. */
|
|
314
|
+
habillee?: boolean
|
|
315
|
+
className?: string
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** ⚠️ Porte `min-height: 0` ET `min-width: 0`. Sans les deux, un enfant flex
|
|
319
|
+
* refuse de rétrécir sous son contenu : la zone déborde et c'est la PAGE qui
|
|
320
|
+
* part en défilement horizontal. C'est la faute la plus fréquente de ce
|
|
321
|
+
* gabarit. */
|
|
322
|
+
export function Scroll({ habillee, className, ...reste }: ScrollProps) {
|
|
323
|
+
return (
|
|
324
|
+
<div
|
|
325
|
+
className={cx('zv-scroll-y', habillee && 'zv-scrollbar', className)}
|
|
326
|
+
{...reste}
|
|
327
|
+
/>
|
|
328
|
+
)
|
|
329
|
+
}
|