@zevra/ui 0.7.0 → 0.9.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 (70) hide show
  1. package/DECISIONS.md +227 -0
  2. package/dist/actions.d.ts +13 -3
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +9 -2
  5. package/dist/actions.js.map +1 -1
  6. package/dist/catalogue.d.ts.map +1 -1
  7. package/dist/catalogue.js +160 -11
  8. package/dist/catalogue.js.map +1 -1
  9. package/dist/css-inline.d.ts.map +1 -1
  10. package/dist/css-inline.js +1 -1
  11. package/dist/css-inline.js.map +1 -1
  12. package/dist/data-table.d.ts +30 -0
  13. package/dist/data-table.d.ts.map +1 -0
  14. package/dist/data-table.js +35 -0
  15. package/dist/data-table.js.map +1 -0
  16. package/dist/data.d.ts +34 -1
  17. package/dist/data.d.ts.map +1 -1
  18. package/dist/data.js +27 -0
  19. package/dist/data.js.map +1 -1
  20. package/dist/feedback.d.ts +49 -0
  21. package/dist/feedback.d.ts.map +1 -1
  22. package/dist/feedback.js +24 -0
  23. package/dist/feedback.js.map +1 -1
  24. package/dist/forms.d.ts +36 -0
  25. package/dist/forms.d.ts.map +1 -1
  26. package/dist/forms.js +24 -0
  27. package/dist/forms.js.map +1 -1
  28. package/dist/icon-data.d.ts +1 -1
  29. package/dist/icon-data.d.ts.map +1 -1
  30. package/dist/icon-data.js +42 -0
  31. package/dist/icon-data.js.map +1 -1
  32. package/dist/icon.d.ts +1 -1
  33. package/dist/icon.js +1 -1
  34. package/dist/layout.d.ts +21 -0
  35. package/dist/layout.d.ts.map +1 -1
  36. package/dist/layout.js +18 -0
  37. package/dist/layout.js.map +1 -1
  38. package/dist/navigation.d.ts +45 -7
  39. package/dist/navigation.d.ts.map +1 -1
  40. package/dist/navigation.js +54 -7
  41. package/dist/navigation.js.map +1 -1
  42. package/dist/surfaces.d.ts +18 -0
  43. package/dist/surfaces.d.ts.map +1 -1
  44. package/dist/surfaces.js +6 -0
  45. package/dist/surfaces.js.map +1 -1
  46. package/icons/index.json +126 -0
  47. package/icons/sprite.svg +21 -0
  48. package/package.json +18 -2
  49. package/react/actions.tsx +28 -2
  50. package/react/catalogue.tsx +343 -17
  51. package/react/css-inline.ts +1 -1
  52. package/react/data-table.tsx +153 -0
  53. package/react/data.tsx +87 -1
  54. package/react/feedback.tsx +98 -0
  55. package/react/forms.tsx +90 -0
  56. package/react/icon-data.ts +63 -0
  57. package/react/icon.tsx +2 -2
  58. package/react/layout.tsx +39 -0
  59. package/react/navigation.tsx +108 -6
  60. package/react/surfaces.tsx +45 -0
  61. package/src/base.css +16 -0
  62. package/src/components.css +135 -3
  63. package/src/data.css +188 -1
  64. package/src/feedback.css +196 -37
  65. package/src/forms.css +137 -1
  66. package/src/layout.css +72 -0
  67. package/src/navigation.css +207 -12
  68. package/src/surfaces.css +76 -7
  69. package/src/tokens.css +6 -0
  70. package/tokens/index.js +2 -0
package/react/layout.tsx CHANGED
@@ -346,3 +346,42 @@ export function OnlyDesktop({ className, ...reste }: Div & { className?: string
346
346
  export function OnlyMobile({ className, ...reste }: Div & { className?: string }) {
347
347
  return <div className={cx('zv-only-mobile', className)} {...reste} />
348
348
  }
349
+
350
+ /* ─── Bandeau d'annonce ────────────────────────────────────────────── */
351
+
352
+ /** La bande AU-DESSUS de la barre de navigation : annonce de version,
353
+ * information réglementaire. UN SEUL par page, et jamais pour du marketing
354
+ * permanent — un bandeau qui ne part pas devient un bourrelet de chrome. */
355
+ export function Banner({ className, ...reste }: Div & { className?: string }) {
356
+ return <div className={cx('zv-banner', className)} {...reste} />
357
+ }
358
+
359
+ /* ─── Carrousel ────────────────────────────────────────────────────── */
360
+
361
+ export interface CarouselProps extends Div {
362
+ /** Ce que la rangée contient (« Nos produits ») : une région qui défile
363
+ * sans nom est une énigme au lecteur d'écran. */
364
+ label: string
365
+ className?: string
366
+ }
367
+
368
+ /** Défilement natif scroll-snap, AUCUN JavaScript : pas de flèches, pas de
369
+ * points. Le conteneur est focalisable — c'est lui que les flèches du
370
+ * clavier font défiler. La largeur des éléments vient du consommateur.
371
+ * ⚠️ Un carrousel reste un aveu d'espace : la règle de variété lui préfère
372
+ * une grille tant que le contenu tient. */
373
+ export function Carousel({ label, className, ...reste }: CarouselProps) {
374
+ return (
375
+ <div
376
+ role="region"
377
+ aria-label={label}
378
+ tabIndex={0}
379
+ className={cx('zv-carousel', className)}
380
+ {...reste}
381
+ />
382
+ )
383
+ }
384
+
385
+ export function CarouselItem({ className, ...reste }: Div & { className?: string }) {
386
+ return <div className={cx('zv-carousel__item', className)} {...reste} />
387
+ }
@@ -12,6 +12,7 @@ import type {
12
12
  AnchorHTMLAttributes,
13
13
  ButtonHTMLAttributes,
14
14
  HTMLAttributes,
15
+ KeyboardEvent as ReactKeyboardEvent,
15
16
  ReactNode,
16
17
  } from 'react'
17
18
  import { cx } from './cx.js'
@@ -182,32 +183,91 @@ export function MenuItem({ actif, className, type = 'button', ...reste }: MenuIt
182
183
  )
183
184
  }
184
185
 
186
+ /* ─── Popover ──────────────────────────────────────────────────────── */
187
+
188
+ /** Surface ancrée LIBRE — aide contextuelle, mini-formulaire de filtre.
189
+ * Même ancre que le menu : à poser dans un `SelectAnchor`, rendu à
190
+ * l'ouverture seulement (`{ouvert && <Popover>…}`). Ce n'est PAS une
191
+ * infobulle : le popover s'ouvre au CLIC et peut porter de l'interactif ;
192
+ * l'infobulle (retours) se montre au survol et n'en porte jamais.
193
+ * ⚠️ Fermeture par clic extérieur et Échap : au composant appelant, comme
194
+ * pour le menu — et le déclencheur porte aria-expanded. */
195
+ export function Popover({ className, ...reste }: Div & { className?: string }) {
196
+ return <div className={cx('zv-popover', className)} {...reste} />
197
+ }
198
+
185
199
  /* ─── Onglets ──────────────────────────────────────────────────────── */
186
200
 
187
201
  export interface TabsProps<T extends string> {
188
202
  onglets: readonly { id: T; label: ReactNode; compte?: ReactNode }[]
189
203
  actif: T
190
204
  onChange: (id: T) => void
191
- /** Rangée qui défile au lieu de passer à la ligne. */
205
+ /** « puces » (défaut) : la rangée de Seg — le motif des FILTRES, qui
206
+ * restreignent une collection. « soulignes » : l'onglet au trait —
207
+ * arbitré le 25/08/2026 (2 px, accent) — pour les VUES d'un même objet
208
+ * (Aperçu / Sources). */
209
+ variante?: 'puces' | 'soulignes'
210
+ /** Rangée qui défile au lieu de passer à la ligne (variante puces ; la
211
+ * rangée soulignée défile toujours dans son cadre). */
192
212
  rail?: boolean
193
213
  /** Identifiant du panneau piloté, pour `aria-controls`. */
194
214
  panneau?: string
195
215
  className?: string
196
216
  }
197
217
 
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. */
218
+ /** Deux dessins, une seule grammaire d'état. En puces, ce composant n'ajoute
219
+ * que l'ARIA au-dessus de `Seg` ; en soulignés, il pose `.zv-tabs-line` —
220
+ * l'arbitrage longtemps en attente, tranché le 25/08/2026 (DECISIONS.md).
221
+ * Les deux portent le contrat clavier du rôle : la rangée est UNE étape de
222
+ * tabulation (roving tabindex), les flèches passent d'un onglet à l'autre
223
+ * et la sélection suit le focus. */
203
224
  export function Tabs<T extends string>({
204
225
  onglets,
205
226
  actif,
206
227
  onChange,
228
+ variante = 'puces',
207
229
  rail,
208
230
  panneau,
209
231
  className,
210
232
  }: TabsProps<T>) {
233
+ function auClavier(e: ReactKeyboardEvent<HTMLButtonElement>) {
234
+ const rangee = e.currentTarget.parentElement
235
+ if (!rangee) return
236
+ const tabs = Array.from(rangee.querySelectorAll<HTMLButtonElement>('[role="tab"]'))
237
+ const i = tabs.indexOf(e.currentTarget)
238
+ let cible = -1
239
+ if (e.key === 'ArrowRight') cible = (i + 1) % tabs.length
240
+ else if (e.key === 'ArrowLeft') cible = (i - 1 + tabs.length) % tabs.length
241
+ else if (e.key === 'Home') cible = 0
242
+ else if (e.key === 'End') cible = tabs.length - 1
243
+ if (cible < 0) return
244
+ e.preventDefault()
245
+ tabs[cible]?.focus()
246
+ onChange(onglets[cible]!.id)
247
+ }
248
+
249
+ if (variante === 'soulignes') {
250
+ return (
251
+ <div role="tablist" className={cx('zv-tabs-line', className)}>
252
+ {onglets.map((o) => (
253
+ <button
254
+ key={o.id}
255
+ type="button"
256
+ role="tab"
257
+ aria-selected={o.id === actif}
258
+ aria-controls={panneau}
259
+ tabIndex={o.id === actif ? 0 : -1}
260
+ className="zv-tabs-line__tab"
261
+ onClick={() => onChange(o.id)}
262
+ onKeyDown={auClavier}
263
+ >
264
+ {o.label}
265
+ {o.compte != null && <span className="zv-tabs-line__count">{o.compte}</span>}
266
+ </button>
267
+ ))}
268
+ </div>
269
+ )
270
+ }
211
271
  return (
212
272
  <SegGroup role="tablist" rail={rail} className={className}>
213
273
  {onglets.map((o) => (
@@ -216,9 +276,11 @@ export function Tabs<T extends string>({
216
276
  role="tab"
217
277
  aria-selected={o.id === actif}
218
278
  aria-controls={panneau}
279
+ tabIndex={o.id === actif ? 0 : -1}
219
280
  on={o.id === actif}
220
281
  compte={o.compte}
221
282
  onClick={() => onChange(o.id)}
283
+ onKeyDown={auClavier}
222
284
  >
223
285
  {o.label}
224
286
  </Seg>
@@ -327,3 +389,43 @@ export function Scroll({ habillee, className, ...reste }: ScrollProps) {
327
389
  />
328
390
  )
329
391
  }
392
+
393
+ /* ─── Barre d'onglets mobile ───────────────────────────────────────── */
394
+
395
+ export interface TabbarProps extends Div {
396
+ /** Le nom de la barre (« Sections ») : un <nav> anonyme de plus est une
397
+ * énigme au lecteur d'écran — même règle que le label de `Carousel`. */
398
+ label: string
399
+ className?: string
400
+ }
401
+
402
+ /** Barre basse de navigation, pour une application à trois ou quatre
403
+ * sections. N'existe que SOUS le point de rupture : au-dessus, la colonne
404
+ * latérale est là. CINQ entrées au maximum — au-delà, c'est le tiroir.
405
+ * ⚠️ Elle est fixe : le gabarit qui la pose doit donner au contenu un
406
+ * padding-bottom à sa hauteur, sinon la dernière ligne se cache dessous. */
407
+ export function Tabbar({ label, className, ...reste }: TabbarProps) {
408
+ return <nav aria-label={label} className={cx('zv-tabbar', className)} {...reste} />
409
+ }
410
+
411
+ export interface TabbarItemProps extends A {
412
+ /** Section courante : pose `aria-current="page"`, que le CSS suit. */
413
+ courant?: boolean
414
+ /** L'icône au-dessus du libellé — un `<Icon>` du paquet. */
415
+ icone?: ReactNode
416
+ className?: string
417
+ children?: ReactNode
418
+ }
419
+
420
+ export function TabbarItem({ courant, icone, className, children, ...reste }: TabbarItemProps) {
421
+ return (
422
+ <a
423
+ aria-current={courant ? 'page' : undefined}
424
+ className={cx('zv-tabbar__item', className)}
425
+ {...reste}
426
+ >
427
+ {icone}
428
+ {children}
429
+ </a>
430
+ )
431
+ }
@@ -159,3 +159,48 @@ export function anim(
159
159
  return `zv-anim-converge zv-anim-converge--${option}`
160
160
  return `zv-anim-${nom}`
161
161
  }
162
+
163
+ /* ─── Formats sociaux ──────────────────────────────────────────────── */
164
+
165
+ export interface SocialFrameProps extends Div {
166
+ /** paysage 1200×630 (OpenGraph, lien LinkedIn) · carre 1080×1080 (post) ·
167
+ * story 1080×1920. FIGÉS en pixels : ce sont des planches qu'on CAPTURE,
168
+ * pas des pages — elles ignorent le point de rupture, et c'est voulu. */
169
+ format?: 'paysage' | 'carre' | 'story'
170
+ /** La planche sombre : le manifeste en fond. Les règles du fond sombre
171
+ * s'appliquent — labels en `Mono ton="on-dark"`, jamais les neutres
172
+ * inversés. */
173
+ manifeste?: boolean
174
+ /** Le pied : wordmark à gauche, adresse en mono à droite. */
175
+ pied?: ReactNode
176
+ className?: string
177
+ children?: ReactNode
178
+ }
179
+
180
+ /** Planche d'export pour les réseaux. ⚠️ La règle « jamais de faux
181
+ * contenu » s'y applique au premier chef : un visuel social EST une
182
+ * publication — pas de chiffre inventé, pas de citation fabriquée. */
183
+ export function SocialFrame({
184
+ format = 'paysage',
185
+ manifeste,
186
+ pied,
187
+ className,
188
+ children,
189
+ ...reste
190
+ }: SocialFrameProps) {
191
+ return (
192
+ <div
193
+ className={cx(
194
+ 'zv-social',
195
+ format === 'carre' && 'zv-social--carre',
196
+ format === 'story' && 'zv-social--story',
197
+ manifeste && 'zv-social--manifeste',
198
+ className,
199
+ )}
200
+ {...reste}
201
+ >
202
+ <div className="zv-social__contenu">{children}</div>
203
+ {pied && <div className="zv-social__pied">{pied}</div>}
204
+ </div>
205
+ )
206
+ }
package/src/base.css CHANGED
@@ -64,6 +64,22 @@ a:not([class]):hover {
64
64
  outline-offset: 2px;
65
65
  }
66
66
 
67
+ /* Texte pour lecteurs d'écran seulement — la géométrie standard du motif,
68
+ * aucune valeur de charte. HORS RELEVÉ (25/08/2026) : ajouté avec la roue,
69
+ * dont le libellé d'annonce doit être un TEXTE dans le flux (un aria-label
70
+ * sur un span vide n'est pas annoncé par tous les lecteurs). */
71
+ .zv-sr-only {
72
+ position: absolute;
73
+ width: 1px;
74
+ height: 1px;
75
+ margin: -1px;
76
+ padding: 0;
77
+ border: 0;
78
+ overflow: hidden;
79
+ clip-path: inset(50%);
80
+ white-space: nowrap;
81
+ }
82
+
67
83
  @media (prefers-reduced-motion: reduce) {
68
84
  *,
69
85
  *::before,
@@ -16,7 +16,8 @@
16
16
 
17
17
  .zv-btn,
18
18
  .zv-btn--ghost,
19
- .zv-btn--ink {
19
+ .zv-btn--ink,
20
+ .zv-btn--danger {
20
21
  display: inline-flex;
21
22
  align-items: center;
22
23
  justify-content: center;
@@ -30,10 +31,21 @@
30
31
  text-decoration: none;
31
32
  border: 0;
32
33
  cursor: pointer;
34
+ /* La courbe maison, 160 ms — coupée globalement par prefers-reduced-motion
35
+ * (base.css). Le fond en dégradé du bouton plein ne s'interpole pas : lui
36
+ * seul bascule sans fondu, l'écart est trop faible pour se voir. */
37
+ transition:
38
+ background-color 160ms cubic-bezier(0.2, 0.7, 0.2, 1),
39
+ border-color 160ms cubic-bezier(0.2, 0.7, 0.2, 1),
40
+ color 160ms cubic-bezier(0.2, 0.7, 0.2, 1),
41
+ box-shadow 160ms cubic-bezier(0.2, 0.7, 0.2, 1);
33
42
  }
34
43
 
35
44
  .zv-btn {
36
- background: linear-gradient(180deg, #2b2bb4 0%, var(--accent-deep) 100%);
45
+ /* Le sommet du dégradé, relevé dans Main.html et SANS token : écrit une
46
+ * seule fois ici, les états de survol le dérivent par color-mix. */
47
+ --zv-btn-haut: #2b2bb4;
48
+ background: linear-gradient(180deg, var(--zv-btn-haut) 0%, var(--accent-deep) 100%);
37
49
  color: var(--white);
38
50
  /* Le liseré interne fait tout le galbe du bouton : il est dans Main.html
39
51
  * mais absent de --shadow-btn, d'où la composition ici. */
@@ -53,6 +65,74 @@
53
65
  color: var(--white);
54
66
  }
55
67
 
68
+ /* Action DESTRUCTIVE : la confirmation d'une modale danger, la suppression
69
+ * définitive. HORS RELEVÉ (25/08/2026, voir DECISIONS.md) : sans elle,
70
+ * « Supprimer » se rendait en plein indigo et se lisait comme n'importe
71
+ * quelle action. Aplat --err, sans dégradé ni ombre indigo.
72
+ * ⚠️ Elle COMPTE comme le bouton plein de l'écran : la règle « un seul plein
73
+ * par page » compte les aplats, pas les couleurs. */
74
+ .zv-btn--danger {
75
+ background: var(--err);
76
+ color: var(--white);
77
+ }
78
+
79
+ /* ─── États de survol et d'appui ────────────────────────────────────────
80
+ * HORS RELEVÉ (25/08/2026, voir DECISIONS.md) : aucune maquette n'a d'état
81
+ * hover — elles sont figées. Le survol ASSOMBRIT, l'appui assombrit
82
+ * davantage et rassoit le bouton (l'ombre tombe, le liseré s'éteint).
83
+ * Les teintes sont DÉRIVÉES par color-mix — l'idiome du voile
84
+ * (overlays.css) — des couleurs que le bouton porte déjà : le sommet de
85
+ * dégradé relevé (--zv-btn-haut, sans token) et --accent-deep. Aucune
86
+ * teinte nouvelle n'entre. Aucun déplacement : les entrées du système
87
+ * montent au défilement, un bouton qui saute au survol raconterait autre
88
+ * chose.
89
+ * Déclarés AVANT [disabled], qui doit l'emporter à spécificité égale. */
90
+
91
+ .zv-btn:hover {
92
+ background: linear-gradient(
93
+ 180deg,
94
+ color-mix(in oklab, var(--zv-btn-haut) 86%, var(--ink)) 0%,
95
+ color-mix(in oklab, var(--accent-deep) 86%, var(--ink)) 100%
96
+ );
97
+ }
98
+
99
+ .zv-btn:active {
100
+ background: linear-gradient(
101
+ 180deg,
102
+ color-mix(in oklab, var(--zv-btn-haut) 72%, var(--ink)) 0%,
103
+ color-mix(in oklab, var(--accent-deep) 72%, var(--ink)) 100%
104
+ );
105
+ box-shadow: none;
106
+ }
107
+
108
+ .zv-btn--ghost:hover {
109
+ border-color: var(--accent);
110
+ }
111
+
112
+ .zv-btn--ghost:active {
113
+ border-color: var(--accent);
114
+ background: var(--accent-soft);
115
+ }
116
+
117
+ /* Un aplat d'encre ne peut pas s'assombrir visiblement : il s'ÉCLAIRCIT au
118
+ * survol, et l'appui revient à l'encre pleine — plus sombre que le survol,
119
+ * c'est ce qui fait lire l'enfoncement. */
120
+ .zv-btn--ink:hover {
121
+ background: color-mix(in oklab, var(--ink) 82%, var(--white));
122
+ }
123
+
124
+ .zv-btn--ink:active {
125
+ background: var(--ink);
126
+ }
127
+
128
+ .zv-btn--danger:hover {
129
+ background: color-mix(in oklab, var(--err) 86%, var(--ink));
130
+ }
131
+
132
+ .zv-btn--danger:active {
133
+ background: color-mix(in oklab, var(--err) 72%, var(--ink));
134
+ }
135
+
56
136
  /* Variante compacte : barres de navigation. L'ombre tombe — un bouton de
57
137
  * chrome ne décolle pas de la page. */
58
138
  .zv-btn--sm {
@@ -68,7 +148,8 @@
68
148
 
69
149
  .zv-btn[disabled],
70
150
  .zv-btn--ghost[disabled],
71
- .zv-btn--ink[disabled] {
151
+ .zv-btn--ink[disabled],
152
+ .zv-btn--danger[disabled] {
72
153
  /* HORS RELEVÉ — aucune maquette ne montre de bouton désactivé. Traitement
73
154
  * minimal et neutre plutôt qu'une couleur inventée. */
74
155
  opacity: 0.5;
@@ -76,6 +157,57 @@
76
157
  box-shadow: none;
77
158
  }
78
159
 
160
+ /* Ni le survol ni l'appui ne doivent rattraper un bouton inerte —
161
+ * désactivé OU occupé (dont le clic est avalé par la façade) : sans ces
162
+ * règles, les états ci-dessus repeignent le fond et le bouton a l'air
163
+ * actif. Même précaution que les contrôles désactivés de forms.css. */
164
+ .zv-btn[disabled]:is(:hover, :active),
165
+ .zv-btn[aria-busy='true']:is(:hover, :active) {
166
+ background: linear-gradient(180deg, var(--zv-btn-haut) 0%, var(--accent-deep) 100%);
167
+ }
168
+
169
+ /* Occupé mais pas désactivé, le bouton plein garde son ombre à l'appui —
170
+ * un enfoncement qui ne déclenche rien serait un mensonge de rendu. Le
171
+ * compact, lui, n'a pas d'ombre à garder. */
172
+ .zv-btn:not(.zv-btn--sm)[aria-busy='true']:active {
173
+ box-shadow:
174
+ 0 1px 0 rgba(255, 255, 255, 0.18) inset,
175
+ var(--shadow-btn);
176
+ }
177
+
178
+ .zv-btn--ghost[disabled]:is(:hover, :active),
179
+ .zv-btn--ghost[aria-busy='true']:is(:hover, :active) {
180
+ border-color: var(--accent-line);
181
+ background: var(--white);
182
+ }
183
+
184
+ .zv-btn--ink[disabled]:is(:hover, :active),
185
+ .zv-btn--ink[aria-busy='true']:is(:hover, :active) {
186
+ background: var(--ink);
187
+ }
188
+
189
+ .zv-btn--danger[disabled]:is(:hover, :active),
190
+ .zv-btn--danger[aria-busy='true']:is(:hover, :active) {
191
+ background: var(--err);
192
+ }
193
+
194
+ /* Attente : le motif de Mémoire (feedback.css le documente) — le LIBELLÉ
195
+ * change (« Connexion… ») pendant que le bouton s'estompe, en
196
+ * `cursor-wait; opacity: .7`, valeurs relevées reprises telles quelles.
197
+ * Codé sur [aria-busy], l'attribut qui rend l'attente audible : le
198
+ * balisage correct ne coûte rien. La façade React l'expose en prop
199
+ * `occupe` — qui pose aria-disabled et AVALE le clic plutôt que de poser
200
+ * `disabled`, pour que le bouton reste dans le parcours clavier pendant
201
+ * que l'attente s'annonce (voir DECISIONS.md). Déclaré APRÈS [disabled]
202
+ * pour l'emporter à spécificité égale quand les deux se combinent. */
203
+ .zv-btn[aria-busy='true'],
204
+ .zv-btn--ghost[aria-busy='true'],
205
+ .zv-btn--ink[aria-busy='true'],
206
+ .zv-btn--danger[aria-busy='true'] {
207
+ cursor: wait;
208
+ opacity: 0.7;
209
+ }
210
+
79
211
  .zv-btn-row {
80
212
  display: flex;
81
213
  align-items: center;
package/src/data.css CHANGED
@@ -5,7 +5,9 @@
5
5
  * les trois registres de cellule, le dégroupage mobile (.zv-unwrap), la
6
6
  * liste sans colonnes, la métadonnée mono, le couple clé-valeur, la
7
7
  * pastille d'initiales, les deux utilitaires de troncature, l'étiquette de
8
- * catégorie, la puce de citation et la vignette de format.
8
+ * catégorie, la puce de citation et la vignette de format — et, depuis le
9
+ * 25/08/2026 (hors relevé, voir DECISIONS.md) : la rangée dépliante, la
10
+ * touche de clavier et le code, en ligne comme en bloc.
9
11
  *
10
12
  * DÉPENDANCES
11
13
  * tokens.css — pour les seules variables qui y sont déclarées ;
@@ -156,6 +158,51 @@
156
158
  * contient aucun, et les inventer ferait apparaître un fond de plus dans un
157
159
  * système qui n'en a que deux (blanc, survol). */
158
160
 
161
+ /* ─── En-tête triable ──────────────────────────────────────────────────
162
+ * ⚠️ HORS RELEVÉ — ajouté le 25/08/2026 avec l'adaptateur TanStack Table.
163
+ * Aucun tableau de la production n'est triable ; l'intitulé y est du texte
164
+ * inerte. Un en-tête qui trie DOIT être un <button> — c'est la seule façon
165
+ * qu'il soit atteignable au clavier et annoncé comme actionnable.
166
+ *
167
+ * `font: inherit` plutôt qu'une police redite : l'en-tête a déjà la sienne
168
+ * (mono, capitales, interlettrée), et un <button> retomberait sur celle du
169
+ * navigateur. Hériter ne fait entrer aucune valeur nouvelle. */
170
+
171
+ .zv-sort {
172
+ display: inline-flex;
173
+ align-items: center;
174
+ gap: 5px;
175
+ border: 0;
176
+ background: none;
177
+ padding: 0;
178
+ font: inherit;
179
+ letter-spacing: inherit;
180
+ text-transform: inherit;
181
+ color: inherit;
182
+ cursor: pointer;
183
+ }
184
+
185
+ .zv-sort:hover {
186
+ color: var(--accent);
187
+ }
188
+
189
+ .zv-sort:focus-visible {
190
+ /* L'en-tête colle au bord du cadre : l'anneau de base.css y serait rogné. */
191
+ outline-offset: -2px;
192
+ }
193
+
194
+ /* La marque de tri reste TOUJOURS posée, même sur une colonne non triée
195
+ * (elle vaut alors ↕) : si elle n'apparaissait qu'au tri, la largeur de la
196
+ * colonne changerait au clic et toute la ligne d'en-tête danserait. */
197
+ .zv-sort__mark {
198
+ font-size: var(--mono-sm);
199
+ color: var(--label);
200
+ }
201
+
202
+ .zv-sort:hover .zv-sort__mark {
203
+ color: var(--accent);
204
+ }
205
+
159
206
  /* ─── Cellules ─────────────────────────────────────────────────────────
160
207
  * Trois registres seulement : le texte courant, le nombre, et l'intitulé qui
161
208
  * remplace l'en-tête quand celui-ci disparaît. */
@@ -644,6 +691,134 @@
644
691
  background: var(--fmt-docx);
645
692
  }
646
693
 
694
+ /* ─── Rangée dépliante ─────────────────────────────────────────────────
695
+ * HORS RELEVÉ (25/08/2026, voir DECISIONS.md). La FAQ est le pliage de la
696
+ * vitrine ; l'applicatif n'en avait aucun — options avancées, détail d'une
697
+ * ligne de journal. Construite sur <details> NATIF : le clavier, l'annonce
698
+ * et l'état ouvert viennent du navigateur, aucun JavaScript.
699
+ * Plusieurs rangées se suivent bord à bord : le filet mitoyen tombe. */
700
+
701
+ .zv-fold {
702
+ border: 1px solid var(--line);
703
+ background: var(--white);
704
+ }
705
+
706
+ .zv-fold + .zv-fold {
707
+ border-top: 0;
708
+ }
709
+
710
+ .zv-fold__titre {
711
+ display: flex;
712
+ align-items: center;
713
+ justify-content: space-between;
714
+ gap: 12px;
715
+ padding: 12px 16px;
716
+ font-size: var(--app);
717
+ font-weight: 600;
718
+ color: var(--ink);
719
+ cursor: pointer;
720
+ /* Le marqueur natif du <summary> tombe : le chevron du composant fait le
721
+ * même travail sans imposer le triangle du navigateur. */
722
+ list-style: none;
723
+ }
724
+
725
+ .zv-fold__titre::-webkit-details-marker {
726
+ display: none;
727
+ }
728
+
729
+ .zv-fold__titre:hover {
730
+ background: var(--panel);
731
+ }
732
+
733
+ /* Le chevron porte sa classe : cibler tout svg du titre attraperait aussi
734
+ * une icône posée dans le libellé, qui se verrait rétrécie et retournée à
735
+ * l'ouverture. */
736
+ .zv-fold__chevron {
737
+ width: 14px;
738
+ height: 14px;
739
+ flex: none;
740
+ color: var(--label);
741
+ /* `rotate` et non `transform` : la propriété indépendante, comme partout
742
+ * où le paquet anime. Coupée par prefers-reduced-motion (base.css). */
743
+ transition: rotate 160ms cubic-bezier(0.2, 0.7, 0.2, 1);
744
+ }
745
+
746
+ .zv-fold[open] > .zv-fold__titre {
747
+ border-bottom: 1px solid var(--hairline);
748
+ }
749
+
750
+ .zv-fold[open] > .zv-fold__titre .zv-fold__chevron {
751
+ rotate: 180deg;
752
+ }
753
+
754
+ .zv-fold__corps {
755
+ padding: 12px 16px;
756
+ font-size: var(--app);
757
+ line-height: 1.6;
758
+ color: var(--muted);
759
+ }
760
+
761
+ /* ─── Touche de clavier ────────────────────────────────────────────────
762
+ * HORS RELEVÉ (25/08/2026). Une documentation d'interface cite des
763
+ * raccourcis ; le paquet n'avait rien pour les poser. Le bas de 2 px fait
764
+ * le relief de la touche — géométrie, pas ombre de charte, comme l'anneau
765
+ * du radio. */
766
+
767
+ .zv-kbd {
768
+ display: inline-block;
769
+ padding: 2px 6px;
770
+ border: 1px solid var(--line);
771
+ border-bottom-width: 2px;
772
+ background: var(--panel);
773
+ font-family: var(--font-mono);
774
+ font-size: var(--mono-lg);
775
+ font-weight: 600;
776
+ white-space: nowrap;
777
+ color: var(--ink-2);
778
+ }
779
+
780
+ /* ─── Code ─────────────────────────────────────────────────────────────
781
+ * HORS RELEVÉ (25/08/2026, voir DECISIONS.md — c'est l'entrée qui a créé
782
+ * --font-code). --font-mono (Space Grotesk) est PROPORTIONNELLE : elle
783
+ * porte le registre des labels, pas l'alignement, et tabular-nums n'aligne
784
+ * que les chiffres. Du code exige la chasse fixe entière : --font-code, la
785
+ * pile système, sans fonte chargée.
786
+ * Aucune coloration syntaxique : elle imposerait une palette entière que la
787
+ * charte n'a pas. Le code se lit à l'encre sur panneau, comme un extrait. */
788
+
789
+ /* En ligne, dans une phrase : `pnpm build`. Taille RELATIVE au texte qui
790
+ * l'accueille — le même extrait sert dans un corps 15 et une note 12,5. */
791
+ .zv-code {
792
+ padding: 1px 5px;
793
+ border: 1px solid var(--hairline);
794
+ background: var(--panel);
795
+ font-family: var(--font-code);
796
+ font-size: 0.85em;
797
+ overflow-wrap: break-word;
798
+ color: var(--ink-2);
799
+ }
800
+
801
+ /* En bloc. Le cadre défile en son sein, comme le tableau : une ligne longue
802
+ * ne pousse jamais la page en défilement horizontal. */
803
+ .zv-codeblock {
804
+ overflow-x: auto;
805
+ margin: 0;
806
+ border: 1px solid var(--line);
807
+ background: var(--panel);
808
+ padding: 14px 16px;
809
+ font-family: var(--font-code);
810
+ font-size: var(--app-sm);
811
+ line-height: 1.7;
812
+ tab-size: 2;
813
+ color: var(--ink);
814
+ }
815
+
816
+ /* Le <code> intérieur n'apporte que la sémantique : sans ce rappel, il
817
+ * retomberait sur la mono du navigateur, à une autre taille. */
818
+ .zv-codeblock code {
819
+ font: inherit;
820
+ }
821
+
647
822
  /* ─── Sous 768px ───────────────────────────────────────────────────────
648
823
  * Point de rupture unique de la charte. Les lignes cessent d'être des lignes :
649
824
  * une grille de six colonnes à 375px produit six colonnes illisibles, ou un
@@ -739,4 +914,16 @@
739
914
  .zv-cite {
740
915
  white-space: normal;
741
916
  }
917
+
918
+ /* L'en-tête du dépliant devient une cible tactile. */
919
+ .zv-fold__titre {
920
+ min-height: var(--tap);
921
+ padding-block: 10px;
922
+ }
923
+
924
+ /* 16px de gouttière interne sur 375px déjà bordés par la page : le bloc
925
+ * se resserre, comme les bandeaux. */
926
+ .zv-codeblock {
927
+ padding: 12px;
928
+ }
742
929
  }