@zevra/ui 0.3.2 → 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.
Files changed (143) hide show
  1. package/DECISIONS.md +25 -0
  2. package/README.md +83 -11
  3. package/dist/actions.d.ts +41 -0
  4. package/dist/actions.d.ts.map +1 -0
  5. package/dist/actions.js +24 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/catalogue.d.ts +30 -0
  8. package/dist/catalogue.d.ts.map +1 -0
  9. package/dist/catalogue.js +830 -0
  10. package/dist/catalogue.js.map +1 -0
  11. package/dist/content.d.ts +135 -0
  12. package/dist/content.d.ts.map +1 -0
  13. package/dist/content.js +85 -0
  14. package/dist/content.js.map +1 -0
  15. package/dist/css-inline.d.ts +11 -0
  16. package/dist/css-inline.d.ts.map +1 -0
  17. package/dist/css-inline.js +13 -0
  18. package/dist/css-inline.js.map +1 -0
  19. package/dist/cx.d.ts +16 -0
  20. package/dist/cx.d.ts.map +1 -0
  21. package/dist/cx.js +20 -0
  22. package/dist/cx.js.map +1 -0
  23. package/dist/data.d.ts +136 -0
  24. package/dist/data.d.ts.map +1 -0
  25. package/dist/data.js +98 -0
  26. package/dist/data.js.map +1 -0
  27. package/dist/feedback.d.ts +88 -0
  28. package/dist/feedback.d.ts.map +1 -0
  29. package/dist/feedback.js +38 -0
  30. package/dist/feedback.js.map +1 -0
  31. package/dist/forms.d.ts +120 -0
  32. package/dist/forms.d.ts.map +1 -0
  33. package/dist/forms.js +60 -0
  34. package/dist/forms.js.map +1 -0
  35. package/dist/icon-data.d.ts +6 -0
  36. package/dist/icon-data.d.ts.map +1 -0
  37. package/dist/icon-data.js +77 -0
  38. package/dist/icon-data.js.map +1 -0
  39. package/dist/icon.d.ts +33 -0
  40. package/dist/icon.d.ts.map +1 -0
  41. package/dist/icon.js +13 -0
  42. package/dist/icon.js.map +1 -0
  43. package/dist/index.d.ts +13 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +41 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/layout.d.ts +166 -0
  48. package/dist/layout.d.ts.map +1 -0
  49. package/dist/layout.js +120 -0
  50. package/dist/layout.js.map +1 -0
  51. package/dist/navigation.d.ts +150 -0
  52. package/dist/navigation.d.ts.map +1 -0
  53. package/dist/navigation.js +104 -0
  54. package/dist/navigation.js.map +1 -0
  55. package/dist/overlays.d.ts +68 -0
  56. package/dist/overlays.d.ts.map +1 -0
  57. package/dist/overlays.js +114 -0
  58. package/dist/overlays.js.map +1 -0
  59. package/dist/surfaces.d.ts +61 -0
  60. package/dist/surfaces.d.ts.map +1 -0
  61. package/dist/surfaces.js +54 -0
  62. package/dist/surfaces.js.map +1 -0
  63. package/dist/typography.d.ts +89 -0
  64. package/dist/typography.d.ts.map +1 -0
  65. package/dist/typography.js +76 -0
  66. package/dist/typography.js.map +1 -0
  67. package/logos/index.json +10 -500
  68. package/package.json +36 -4
  69. package/react/actions.tsx +138 -0
  70. package/react/catalogue.tsx +1644 -0
  71. package/react/content.tsx +313 -0
  72. package/react/css-inline.ts +14 -0
  73. package/react/cx.ts +20 -0
  74. package/react/data.tsx +300 -0
  75. package/react/feedback.tsx +229 -0
  76. package/react/forms.tsx +383 -0
  77. package/react/icon-data.ts +114 -0
  78. package/react/icon.tsx +84 -0
  79. package/react/index.ts +42 -0
  80. package/react/layout.tsx +347 -0
  81. package/react/navigation.tsx +329 -0
  82. package/react/overlays.tsx +237 -0
  83. package/react/surfaces.tsx +161 -0
  84. package/react/typography.tsx +220 -0
  85. package/logos/png/campus-glyph-ink.png +0 -0
  86. package/logos/png/campus-glyph-white.png +0 -0
  87. package/logos/png/campus-lockup.png +0 -0
  88. package/logos/png/lexform-glyph-ink.png +0 -0
  89. package/logos/png/lexform-glyph-white.png +0 -0
  90. package/logos/png/lexform-lockup.png +0 -0
  91. package/logos/png/mcp-glyph-ink.png +0 -0
  92. package/logos/png/mcp-glyph-white.png +0 -0
  93. package/logos/png/mcp-lockup.png +0 -0
  94. package/logos/png/memoire-dark.png +0 -0
  95. package/logos/png/memoire-glyph-ink.png +0 -0
  96. package/logos/png/memoire-glyph-white.png +0 -0
  97. package/logos/png/memoire-lockup.png +0 -0
  98. package/logos/png/zevra-lockup-ink.png +0 -0
  99. package/logos/png/zevra-lockup-white.png +0 -0
  100. package/logos/png/zevra-lockup.png +0 -0
  101. package/logos/png/zevra-mark-ink.png +0 -0
  102. package/logos/png/zevra-mark-white.png +0 -0
  103. package/logos/png/zevra-mark.png +0 -0
  104. package/logos/svg/campus-glyph-ink.svg +0 -1
  105. package/logos/svg/campus-glyph-navy.svg +0 -1
  106. package/logos/svg/campus-glyph-orig.svg +0 -1
  107. package/logos/svg/campus-glyph-white.svg +0 -1
  108. package/logos/svg/campus-lockup-ink.svg +0 -1
  109. package/logos/svg/campus-lockup-navy.svg +0 -1
  110. package/logos/svg/campus-lockup-orig.svg +0 -1
  111. package/logos/svg/campus-lockup-white.svg +0 -1
  112. package/logos/svg/lexform-glyph-ink.svg +0 -1
  113. package/logos/svg/lexform-glyph-navy.svg +0 -1
  114. package/logos/svg/lexform-glyph-orig.svg +0 -1
  115. package/logos/svg/lexform-glyph-white.svg +0 -1
  116. package/logos/svg/lexform-lockup-ink.svg +0 -1
  117. package/logos/svg/lexform-lockup-navy.svg +0 -1
  118. package/logos/svg/lexform-lockup-orig.svg +0 -1
  119. package/logos/svg/lexform-lockup-white.svg +0 -1
  120. package/logos/svg/mcp-glyph-ink.svg +0 -1
  121. package/logos/svg/mcp-glyph-navy.svg +0 -1
  122. package/logos/svg/mcp-glyph-orig.svg +0 -1
  123. package/logos/svg/mcp-glyph-white.svg +0 -1
  124. package/logos/svg/mcp-lockup-ink.svg +0 -1
  125. package/logos/svg/mcp-lockup-navy.svg +0 -1
  126. package/logos/svg/mcp-lockup-orig.svg +0 -1
  127. package/logos/svg/mcp-lockup-white.svg +0 -1
  128. package/logos/svg/memoire-glyph-ink.svg +0 -1
  129. package/logos/svg/memoire-glyph-navy.svg +0 -1
  130. package/logos/svg/memoire-glyph-orig.svg +0 -1
  131. package/logos/svg/memoire-glyph-white.svg +0 -1
  132. package/logos/svg/memoire-lockup-ink.svg +0 -1
  133. package/logos/svg/memoire-lockup-navy.svg +0 -1
  134. package/logos/svg/memoire-lockup-orig.svg +0 -1
  135. package/logos/svg/memoire-lockup-white.svg +0 -1
  136. package/logos/svg/zevra-lockup-ink.svg +0 -1
  137. package/logos/svg/zevra-lockup-navy.svg +0 -1
  138. package/logos/svg/zevra-lockup-orig.svg +0 -1
  139. package/logos/svg/zevra-lockup-white.svg +0 -1
  140. package/logos/svg/zevra-mark-ink.svg +0 -1
  141. package/logos/svg/zevra-mark-navy.svg +0 -1
  142. package/logos/svg/zevra-mark-orig.svg +0 -1
  143. package/logos/svg/zevra-mark-white.svg +0 -1
@@ -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
+ }