@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.
Files changed (82) hide show
  1. package/README.md +64 -1
  2. package/dist/actions.d.ts +41 -0
  3. package/dist/actions.d.ts.map +1 -0
  4. package/dist/actions.js +24 -0
  5. package/dist/actions.js.map +1 -0
  6. package/dist/catalogue.d.ts +30 -0
  7. package/dist/catalogue.d.ts.map +1 -0
  8. package/dist/catalogue.js +830 -0
  9. package/dist/catalogue.js.map +1 -0
  10. package/dist/content.d.ts +135 -0
  11. package/dist/content.d.ts.map +1 -0
  12. package/dist/content.js +85 -0
  13. package/dist/content.js.map +1 -0
  14. package/dist/css-inline.d.ts +11 -0
  15. package/dist/css-inline.d.ts.map +1 -0
  16. package/dist/css-inline.js +13 -0
  17. package/dist/css-inline.js.map +1 -0
  18. package/dist/cx.d.ts +16 -0
  19. package/dist/cx.d.ts.map +1 -0
  20. package/dist/cx.js +20 -0
  21. package/dist/cx.js.map +1 -0
  22. package/dist/data.d.ts +136 -0
  23. package/dist/data.d.ts.map +1 -0
  24. package/dist/data.js +98 -0
  25. package/dist/data.js.map +1 -0
  26. package/dist/feedback.d.ts +88 -0
  27. package/dist/feedback.d.ts.map +1 -0
  28. package/dist/feedback.js +38 -0
  29. package/dist/feedback.js.map +1 -0
  30. package/dist/forms.d.ts +120 -0
  31. package/dist/forms.d.ts.map +1 -0
  32. package/dist/forms.js +60 -0
  33. package/dist/forms.js.map +1 -0
  34. package/dist/icon-data.d.ts +6 -0
  35. package/dist/icon-data.d.ts.map +1 -0
  36. package/dist/icon-data.js +77 -0
  37. package/dist/icon-data.js.map +1 -0
  38. package/dist/icon.d.ts +33 -0
  39. package/dist/icon.d.ts.map +1 -0
  40. package/dist/icon.js +13 -0
  41. package/dist/icon.js.map +1 -0
  42. package/dist/index.d.ts +13 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +41 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/layout.d.ts +166 -0
  47. package/dist/layout.d.ts.map +1 -0
  48. package/dist/layout.js +120 -0
  49. package/dist/layout.js.map +1 -0
  50. package/dist/navigation.d.ts +150 -0
  51. package/dist/navigation.d.ts.map +1 -0
  52. package/dist/navigation.js +104 -0
  53. package/dist/navigation.js.map +1 -0
  54. package/dist/overlays.d.ts +68 -0
  55. package/dist/overlays.d.ts.map +1 -0
  56. package/dist/overlays.js +114 -0
  57. package/dist/overlays.js.map +1 -0
  58. package/dist/surfaces.d.ts +61 -0
  59. package/dist/surfaces.d.ts.map +1 -0
  60. package/dist/surfaces.js +54 -0
  61. package/dist/surfaces.js.map +1 -0
  62. package/dist/typography.d.ts +89 -0
  63. package/dist/typography.d.ts.map +1 -0
  64. package/dist/typography.js +76 -0
  65. package/dist/typography.js.map +1 -0
  66. package/package.json +36 -4
  67. package/react/actions.tsx +138 -0
  68. package/react/catalogue.tsx +1644 -0
  69. package/react/content.tsx +313 -0
  70. package/react/css-inline.ts +14 -0
  71. package/react/cx.ts +20 -0
  72. package/react/data.tsx +300 -0
  73. package/react/feedback.tsx +229 -0
  74. package/react/forms.tsx +383 -0
  75. package/react/icon-data.ts +114 -0
  76. package/react/icon.tsx +84 -0
  77. package/react/index.ts +42 -0
  78. package/react/layout.tsx +347 -0
  79. package/react/navigation.tsx +329 -0
  80. package/react/overlays.tsx +237 -0
  81. package/react/surfaces.tsx +161 -0
  82. package/react/typography.tsx +220 -0
package/react/data.tsx ADDED
@@ -0,0 +1,300 @@
1
+ // Données — tableau en grille, listes, métadonnées, clé-valeur, vignettes.
2
+ //
3
+ // Le tableau du système n'est PAS un <table> : c'est une grille CSS, parce
4
+ // qu'une même rangée doit se rendre en colonnes au-dessus du point de rupture
5
+ // et en carte empilée en dessous, sans dupliquer le balisage. `Unwrap` est la
6
+ // primitive qui rend cela possible.
7
+
8
+ import type { AnchorHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react'
9
+ import { cx } from './cx.js'
10
+
11
+ type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
12
+ type Span = Omit<HTMLAttributes<HTMLSpanElement>, 'className'>
13
+
14
+ /** `--zv-cols` est un PARAMÈTRE de grille, pas un token de marque : il se
15
+ * pose en style inline sur le cadre, et l'en-tête comme les lignes en
16
+ * héritent. Une seule déclaration pour les deux — recopié aux deux
17
+ * endroits, on l'oublie à l'un et les intitulés se décalent des valeurs
18
+ * sans que rien ne casse visiblement. */
19
+ function cols(gabarit: string | undefined, style: CSSProperties | undefined) {
20
+ return gabarit ? ({ ...style, ['--zv-cols']: gabarit } as CSSProperties) : style
21
+ }
22
+
23
+ /* ─── Tableau ──────────────────────────────────────────────────────── */
24
+
25
+ export interface TableProps extends Div {
26
+ /** Gabarit de colonnes, en syntaxe `grid-template-columns`
27
+ * (ex. `'minmax(0,2fr) 120px 90px'`). Hérité par l'en-tête et les lignes. */
28
+ gabarit?: string
29
+ className?: string
30
+ }
31
+
32
+ /** Le tableau défile DANS son cadre : sans ce confinement, une colonne trop
33
+ * large fait défiler la page entière et le rail de navigation part avec. */
34
+ export function Table({ gabarit, className, style, ...reste }: TableProps) {
35
+ return (
36
+ <div className={cx('zv-table', className)} style={cols(gabarit, style)} {...reste} />
37
+ )
38
+ }
39
+
40
+ export interface TableHeadProps extends Div {
41
+ /** En-tête en retrait, pour un tableau posé dans une carte. */
42
+ inset?: boolean
43
+ className?: string
44
+ }
45
+
46
+ export function TableHead({ inset, className, ...reste }: TableHeadProps) {
47
+ return (
48
+ <div
49
+ className={cx('zv-table__head', inset && 'zv-table__head--inset', className)}
50
+ {...reste}
51
+ />
52
+ )
53
+ }
54
+
55
+ export interface RowProps extends Div {
56
+ /** Rend la rangée comme un lien : la cible entière devient cliquable, ce
57
+ * qu'un `onClick` sur un `<div>` ne donne jamais (ni clavier, ni ouverture
58
+ * dans un nouvel onglet). */
59
+ href?: string
60
+ className?: string
61
+ }
62
+
63
+ export function Row({ href, className, ...reste }: RowProps) {
64
+ if (href) {
65
+ return (
66
+ <a
67
+ href={href}
68
+ className={cx('zv-row', className)}
69
+ {...(reste as AnchorHTMLAttributes<HTMLAnchorElement>)}
70
+ />
71
+ )
72
+ }
73
+ return <div className={cx('zv-row', className)} {...reste} />
74
+ }
75
+
76
+ export interface CellProps extends Div {
77
+ /** `nom` : la colonne d'identité, la seule en encre pleine.
78
+ * `num` : chiffres alignés à droite, en chasse tabulaire. */
79
+ registre?: 'nom' | 'num'
80
+ /** Intitulé qui REMPLACE l'en-tête quand celui-ci disparaît (mobile). */
81
+ label?: ReactNode
82
+ /** Unité accolée au nombre, en plus petit. */
83
+ unite?: ReactNode
84
+ className?: string
85
+ children?: ReactNode
86
+ }
87
+
88
+ export function Cell({ registre, label, unite, className, children, ...reste }: CellProps) {
89
+ return (
90
+ <div
91
+ className={cx(
92
+ 'zv-cell',
93
+ registre === 'nom' && 'zv-cell--name',
94
+ registre === 'num' && 'zv-cell--num',
95
+ className,
96
+ )}
97
+ {...reste}
98
+ >
99
+ {label && <span className="zv-cell__label">{label}</span>}
100
+ {children}
101
+ {unite && <span className="zv-cell__unit">{unite}</span>}
102
+ </div>
103
+ )
104
+ }
105
+
106
+ /** Groupe deux ou trois cellules pour l'empilement mobile, puis s'efface
107
+ * (`display: contents`) et rend ses enfants DIRECTEMENT à la grille.
108
+ * ⚠️ N'exprime QUE ce sens-là : le mouvement inverse (pile au-dessus du
109
+ * seuil, colonnes en dessous) n'est pas cette classe. */
110
+ export function Unwrap({ className, ...reste }: Div & { className?: string }) {
111
+ return <div className={cx('zv-unwrap', className)} {...reste} />
112
+ }
113
+
114
+ /* ─── Liste sans colonnes ──────────────────────────────────────────── */
115
+
116
+ export function List({ className, ...reste }: Div & { className?: string }) {
117
+ return <div className={cx('zv-list', className)} {...reste} />
118
+ }
119
+
120
+ export interface ListRowProps extends Div {
121
+ /** Rangée de dépôt en cours : gabarit propre à l'écran d'import. */
122
+ upload?: boolean
123
+ className?: string
124
+ }
125
+
126
+ export function ListRow({ upload, className, ...reste }: ListRowProps) {
127
+ return (
128
+ <div
129
+ className={cx('zv-list__row', upload && 'zv-list__row--upload', className)}
130
+ {...reste}
131
+ />
132
+ )
133
+ }
134
+
135
+ export function ListMain({ className, ...reste }: Div & { className?: string }) {
136
+ return <div className={cx('zv-list__main', className)} {...reste} />
137
+ }
138
+
139
+ export function ListName({ className, ...reste }: Span & { className?: string }) {
140
+ return <span className={cx('zv-list__name', className)} {...reste} />
141
+ }
142
+
143
+ /* ─── Métadonnée mono ──────────────────────────────────────────────── */
144
+
145
+ export interface MetaProps extends Span {
146
+ taille?: 'sm' | 'normal' | 'md'
147
+ /** Encore plus effacée : contexte de second plan. */
148
+ discret?: boolean
149
+ className?: string
150
+ }
151
+
152
+ /** Second registre mono du système, en BAS DE CASSE : taille de fichier,
153
+ * date, horodatage, latence. Ni capitales ni interlettrage — c'est ce qui le
154
+ * distingue de `Tag` et `Mono`, et une date en capitales espacées devient
155
+ * illisible. */
156
+ export function Meta({ taille = 'normal', discret, className, ...reste }: MetaProps) {
157
+ return (
158
+ <span
159
+ className={cx(
160
+ 'zv-meta',
161
+ taille === 'sm' && 'zv-meta--sm',
162
+ taille === 'md' && 'zv-meta--md',
163
+ discret && 'zv-meta--quiet',
164
+ className,
165
+ )}
166
+ {...reste}
167
+ />
168
+ )
169
+ }
170
+
171
+ export function MetaRow({ className, ...reste }: Div & { className?: string }) {
172
+ return <div className={cx('zv-meta-row', className)} {...reste} />
173
+ }
174
+
175
+ /* ─── Clé-valeur ───────────────────────────────────────────────────── */
176
+
177
+ export interface KVProps extends Div {
178
+ /** `grid` : deux colonnes alignées. `foot` : bloc de pied de fiche. */
179
+ variante?: 'grid' | 'foot'
180
+ className?: string
181
+ }
182
+
183
+ export function KV({ variante, className, ...reste }: KVProps) {
184
+ return (
185
+ <div className={cx('zv-kv', variante && `zv-kv--${variante}`, className)} {...reste} />
186
+ )
187
+ }
188
+
189
+ export interface KVRowProps extends Div {
190
+ /** ⚠️ La CLÉ est en mono, en accent, et se coupe ; la VALEUR est en texte
191
+ * courant, en gris, et ne se coupe jamais. Inverser les deux change ce que
192
+ * l'œil balaie en premier dans une colonne de métadonnées. */
193
+ cle: ReactNode
194
+ className?: string
195
+ children?: ReactNode
196
+ }
197
+
198
+ export function KVRow({ cle, className, children, ...reste }: KVRowProps) {
199
+ return (
200
+ <div className={cx('zv-kv__row', className)} {...reste}>
201
+ <span className="zv-kv__key">{cle}</span>
202
+ <span className="zv-kv__val">{children}</span>
203
+ </div>
204
+ )
205
+ }
206
+
207
+ /* ─── Pastille d'initiales ─────────────────────────────────────────── */
208
+
209
+ export interface AvatarProps extends Span {
210
+ /** ⚠️ La teinte dit la NATURE de l'entité, elle n'est pas décorative :
211
+ * la changer, c'est changer le sens. */
212
+ ton?: 'accent' | 'ink' | 'soft'
213
+ petit?: boolean
214
+ className?: string
215
+ }
216
+
217
+ /** CARRÉE, jamais ronde : le rond est un usage de réseau social, l'encre et
218
+ * le papier posent des blocs. */
219
+ export function Avatar({ ton, petit, className, ...reste }: AvatarProps) {
220
+ return (
221
+ <span
222
+ aria-hidden="true"
223
+ className={cx(
224
+ 'zv-avatar',
225
+ ton && `zv-avatar--${ton}`,
226
+ petit && 'zv-avatar--sm',
227
+ className,
228
+ )}
229
+ {...reste}
230
+ />
231
+ )
232
+ }
233
+
234
+ /* ─── Troncature ───────────────────────────────────────────────────── */
235
+
236
+ /** Coupe sur une ligne avec des points de suspension. Jamais interchangeable
237
+ * avec `Break`, qui revient à la ligne. */
238
+ export function Trunc({ className, ...reste }: Span & { className?: string }) {
239
+ return <span className={cx('zv-trunc', className)} {...reste} />
240
+ }
241
+
242
+ /** Revient à la ligne au milieu d'un mot : jetons, URLs, chemins. */
243
+ export function Break({ className, ...reste }: Span & { className?: string }) {
244
+ return <span className={cx('zv-break', className)} {...reste} />
245
+ }
246
+
247
+ /* ─── Étiquette de catégorie ───────────────────────────────────────── */
248
+
249
+ export interface ChipProps extends Span {
250
+ /** `mono` : identifiant, code. `outline` : filet au lieu du fond. */
251
+ variante?: 'mono' | 'outline'
252
+ className?: string
253
+ }
254
+
255
+ /** Étiquette NON porteuse d'alerte : nature d'entité, matière, mot-clé.
256
+ * À ne pas confondre avec `Badge` (couleur sémantique) ni `Tag` (kicker
257
+ * éditorial). Une catégorie n'a pas à crier. */
258
+ export function Chip({ variante, className, ...reste }: ChipProps) {
259
+ return (
260
+ <span
261
+ className={cx('zv-chip', variante && `zv-chip--${variante}`, className)}
262
+ {...reste}
263
+ />
264
+ )
265
+ }
266
+
267
+ export function ChipRow({ className, ...reste }: Div & { className?: string }) {
268
+ return <div className={cx('zv-chip-row', className)} {...reste} />
269
+ }
270
+
271
+ /* ─── Citation ─────────────────────────────────────────────────────── */
272
+
273
+ export function Cite({
274
+ className,
275
+ ...reste
276
+ }: Omit<HTMLAttributes<HTMLButtonElement>, 'className'> & { className?: string }) {
277
+ return <button type="button" className={cx('zv-cite', className)} {...reste} />
278
+ }
279
+
280
+ export function CiteRow({ className, ...reste }: Div & { className?: string }) {
281
+ return <div className={cx('zv-cite-row', className)} {...reste} />
282
+ }
283
+
284
+ /* ─── Vignette de format ───────────────────────────────────────────── */
285
+
286
+ export interface FileTypeProps extends Span {
287
+ /** Règle de choix relevée : DOCX si le type MIME contient « word »,
288
+ * PDF sinon. Les couleurs ne viennent PAS de la charte — ce sont celles
289
+ * que le monde associe à ces formats. */
290
+ format: 'pdf' | 'docx'
291
+ className?: string
292
+ }
293
+
294
+ export function FileType({ format, className, ...reste }: FileTypeProps) {
295
+ return (
296
+ <span className={cx('zv-filetype', `zv-filetype--${format}`, className)} {...reste}>
297
+ {format.toUpperCase()}
298
+ </span>
299
+ )
300
+ }
@@ -0,0 +1,229 @@
1
+ // Retours — bandeaux d'état, étiquettes, pastilles, absence, travail en cours.
2
+ //
3
+ // Rien ici n'est interactif : pas de fermeture, pas de survol. Aucun de ces
4
+ // composants n'a besoin de tourner côté navigateur.
5
+ //
6
+ // ⚠️ Ce fichier ne livre NI roue de chargement, NI squelette, NI infobulle :
7
+ // le CSS n'en a pas, parce que le relevé de production n'en contenait aucun.
8
+ // Le travail en cours se dit par `Loading`, qui nomme ce qu'on attend — une
9
+ // roue dit « attendez », cette phrase dit sur quoi.
10
+
11
+ import type { HTMLAttributes, ReactNode } from 'react'
12
+ import { cx } from './cx.js'
13
+
14
+ type Div = Omit<HTMLAttributes<HTMLDivElement>, 'className'>
15
+ type Span = Omit<HTMLAttributes<HTMLSpanElement>, 'className'>
16
+
17
+ /* ─── Bandeaux d'état ──────────────────────────────────────────────── */
18
+
19
+ export type AlertTon = 'info' | 'ok' | 'warn' | 'err' | 'dash'
20
+
21
+ export interface AlertProps extends Div {
22
+ /** OBLIGATOIRE : sans ton, le bandeau hérite d'un filet en `currentColor`,
23
+ * soit un trait d'encre autour d'un fond blanc. */
24
+ ton: AlertTon
25
+ /** `sm` resserre, `lg` amplifie — ce dernier pour le message qui REMPLACE
26
+ * un contenu attendu (incident, réponse impossible). */
27
+ taille?: 'sm' | 'normal' | 'lg'
28
+ className?: string
29
+ }
30
+
31
+ export function Alert({ ton, taille = 'normal', className, ...reste }: AlertProps) {
32
+ return (
33
+ <div
34
+ role="alert"
35
+ className={cx(
36
+ 'zv-alert',
37
+ `zv-alert--${ton}`,
38
+ taille === 'sm' && 'zv-alert--sm',
39
+ taille === 'lg' && 'zv-alert--lg',
40
+ className,
41
+ )}
42
+ {...reste}
43
+ />
44
+ )
45
+ }
46
+
47
+ /* ─── Étiquettes d'état ────────────────────────────────────────────── */
48
+
49
+ export type BadgeTon = 'neutre' | 'accent' | 'ok' | 'warn' | 'err'
50
+
51
+ export interface BadgeProps extends Span {
52
+ ton?: BadgeTon
53
+ /** `micro` : niveau de log en tête de ligne, badge de coque.
54
+ * `state` : état de service (OK / CHARGE). */
55
+ gabarit?: 'micro' | 'state'
56
+ /** Pastille en tête, de la couleur du texte. */
57
+ pastille?: boolean
58
+ className?: string
59
+ children?: ReactNode
60
+ }
61
+
62
+ /** Le libellé s'écrit EN CAPITALES dans le contenu, pas en CSS : transformé
63
+ * par le navigateur, « INDEXÉ » se casse selon la locale. */
64
+ export function Badge({
65
+ ton = 'neutre',
66
+ gabarit,
67
+ pastille,
68
+ className,
69
+ children,
70
+ ...reste
71
+ }: BadgeProps) {
72
+ return (
73
+ <span
74
+ className={cx(
75
+ 'zv-badge',
76
+ `zv-badge--${ton}`,
77
+ gabarit === 'micro' && 'zv-badge--micro',
78
+ gabarit === 'state' && 'zv-badge--state',
79
+ pastille && 'zv-badge--dot',
80
+ className,
81
+ )}
82
+ {...reste}
83
+ >
84
+ {pastille && <span className="zv-dot" aria-hidden="true" />}
85
+ {children}
86
+ </span>
87
+ )
88
+ }
89
+
90
+ /* ─── Pastilles ────────────────────────────────────────────────────── */
91
+
92
+ export type DotEtat = 'recu' | 'ocr' | 'indexation' | 'indexe' | 'ok' | 'erreur'
93
+
94
+ export interface DotProps extends Span {
95
+ /** Sans état, la pastille reste GRISE : un statut inconnu — ajouté côté
96
+ * serveur, pas encore côté interface — ne doit pas passer pour un succès. */
97
+ etat?: DotEtat
98
+ taille?: 'sm' | 'normal' | 'lg'
99
+ /** CARRÉE : marque une SOURCE, pas un état. Ne pas uniformiser. */
100
+ carree?: boolean
101
+ className?: string
102
+ }
103
+
104
+ export function Dot({ etat, taille = 'normal', carree, className, ...reste }: DotProps) {
105
+ return (
106
+ <span
107
+ aria-hidden="true"
108
+ className={cx(
109
+ 'zv-dot',
110
+ etat && `zv-dot--${etat}`,
111
+ taille === 'sm' && 'zv-dot--sm',
112
+ taille === 'lg' && 'zv-dot--lg',
113
+ carree && 'zv-dot--square',
114
+ className,
115
+ )}
116
+ {...reste}
117
+ />
118
+ )
119
+ }
120
+
121
+ /* ─── Statut en clair ──────────────────────────────────────────────── */
122
+
123
+ export interface StatusProps extends Div {
124
+ etat?: DotEtat
125
+ /** Le message d'erreur REMPLACE le libellé quand il y en a un :
126
+ * l'utilisateur veut lire la cause, pas le mot « Erreur ». */
127
+ children?: ReactNode
128
+ className?: string
129
+ }
130
+
131
+ export function Status({ etat, className, children, ...reste }: StatusProps) {
132
+ return (
133
+ <div className={cx('zv-status', className)} {...reste}>
134
+ <Dot etat={etat} />
135
+ <span className="zv-status__label">{children}</span>
136
+ </div>
137
+ )
138
+ }
139
+
140
+ /* ─── Absence ──────────────────────────────────────────────────────── */
141
+
142
+ export interface EmptyProps extends Div {
143
+ /** ⚠️ DEUX OBJETS DISTINCTS. Sans `titre` c'est la LIGNE d'absence : un
144
+ * filtre trop étroit, la collection existe, l'en-tête du tableau reste
145
+ * visible. Avec `titre` et `page`, c'est l'ÉCRAN d'absence : la collection
146
+ * est vide et il faut lui offrir une porte de sortie. Les confondre
147
+ * affiche un écran de bienvenue à chaque filtre trop strict. */
148
+ titre?: ReactNode
149
+ page?: boolean
150
+ /** L'action qui remplira la collection. Réservée à l'écran d'absence. */
151
+ action?: ReactNode
152
+ className?: string
153
+ children?: ReactNode
154
+ }
155
+
156
+ export function Empty({ titre, page, action, className, children, ...reste }: EmptyProps) {
157
+ return (
158
+ <div className={cx('zv-empty', page && 'zv-empty--page', className)} {...reste}>
159
+ {titre && <p className="zv-empty__title">{titre}</p>}
160
+ {children && <p className="zv-empty__text">{children}</p>}
161
+ {action && <div className="zv-empty__action">{action}</div>}
162
+ </div>
163
+ )
164
+ }
165
+
166
+ /* ─── Travail en cours ─────────────────────────────────────────────── */
167
+
168
+ export interface LoadingProps extends Div {
169
+ className?: string
170
+ children?: ReactNode
171
+ }
172
+
173
+ /** Pastille + phrase qui dit le travail réel (« Recherche dans 4 812
174
+ * documents anonymisés… »). STATIQUE : la pastille ne bat pas, et lui
175
+ * ajouter un pouls est l'invention la plus tentante du système. */
176
+ export function Loading({ className, children, ...reste }: LoadingProps) {
177
+ return (
178
+ <div
179
+ role="status"
180
+ aria-live="polite"
181
+ className={cx('zv-loading', className)}
182
+ {...reste}
183
+ >
184
+ <span className="zv-dot" aria-hidden="true" />
185
+ {children}
186
+ </div>
187
+ )
188
+ }
189
+
190
+ /** Bloc collé à la fin d'un texte en cours de diffusion, sur le DERNIER
191
+ * message seulement : sans lui, une réponse coupée à mi-phrase se lit comme
192
+ * une réponse complète et fausse. */
193
+ export function Caret({ className, ...reste }: Span & { className?: string }) {
194
+ return (
195
+ <span className={cx('zv-caret', className)} aria-hidden="true" {...reste}>
196
+ ▍
197
+ </span>
198
+ )
199
+ }
200
+
201
+ /* ─── Barre de progression ─────────────────────────────────────────── */
202
+
203
+ export interface ProgressProps extends Div {
204
+ /** Entre 0 et 1. Hors bornes, la valeur est ramenée dedans : une barre à
205
+ * 140 % déborde de son cadre. */
206
+ valeur: number
207
+ /** Ce que la barre mesure — annoncé, jamais dessiné. */
208
+ label?: string
209
+ className?: string
210
+ }
211
+
212
+ /** Deuxième des trois emplois autorisés du spectre (avec le filet de section
213
+ * et les points de méthode). Jamais en fond, jamais en texte. */
214
+ export function Progress({ valeur, label, className, ...reste }: ProgressProps) {
215
+ const part = Math.min(1, Math.max(0, valeur))
216
+ return (
217
+ <div
218
+ role="progressbar"
219
+ aria-valuemin={0}
220
+ aria-valuemax={100}
221
+ aria-valuenow={Math.round(part * 100)}
222
+ aria-label={label}
223
+ className={cx('zv-progress', className)}
224
+ {...reste}
225
+ >
226
+ <div className="zv-progress__fill" style={{ width: `${part * 100}%` }} />
227
+ </div>
228
+ )
229
+ }