@sia-ui/cli 0.7.0 → 0.8.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 (100) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +12 -0
  3. package/dist/registry/package/components/AccountMenu/index.tsx +107 -0
  4. package/dist/registry/package/components/AccountMenu/styles.css +30 -0
  5. package/dist/registry/package/components/ActivityLog/index.tsx +96 -25
  6. package/dist/registry/package/components/AmountDisplay/index.tsx +5 -2
  7. package/dist/registry/package/components/AppShell/index.tsx +44 -8
  8. package/dist/registry/package/components/AppShell/tab-bar.tsx +6 -3
  9. package/dist/registry/package/components/AsyncState/index.tsx +29 -7
  10. package/dist/registry/package/components/AuditMeta/index.tsx +12 -2
  11. package/dist/registry/package/components/Avatar/index.tsx +4 -9
  12. package/dist/registry/package/components/Badge/index.tsx +3 -2
  13. package/dist/registry/package/components/Badge/styles.css +4 -4
  14. package/dist/registry/package/components/BrandIcon/index.tsx +100 -0
  15. package/dist/registry/package/components/BrandIcon/styles.css +31 -0
  16. package/dist/registry/package/components/Breadcrumb/index.tsx +14 -2
  17. package/dist/registry/package/components/Button/index.tsx +3 -2
  18. package/dist/registry/package/components/Button/styles.css +4 -4
  19. package/dist/registry/package/components/Calendar/index.tsx +7 -3
  20. package/dist/registry/package/components/Card/index.tsx +14 -4
  21. package/dist/registry/package/components/ColorModeToggle/index.tsx +44 -0
  22. package/dist/registry/package/components/CommandPalette/index.tsx +3 -2
  23. package/dist/registry/package/components/ConfirmDialog/index.tsx +24 -4
  24. package/dist/registry/package/components/CrudPage/dialogs.tsx +53 -8
  25. package/dist/registry/package/components/CrudPage/index.tsx +99 -25
  26. package/dist/registry/package/components/CurrencyInput/index.tsx +5 -2
  27. package/dist/registry/package/components/DataTable/index.tsx +53 -18
  28. package/dist/registry/package/components/DataTable/row-actions.tsx +20 -8
  29. package/dist/registry/package/components/DataTable/types.ts +100 -0
  30. package/dist/registry/package/components/DatePicker/index.tsx +14 -6
  31. package/dist/registry/package/components/DateRangeFilter/index.tsx +12 -3
  32. package/dist/registry/package/components/DateRangePicker/index.tsx +13 -4
  33. package/dist/registry/package/components/DateTimePicker/index.tsx +3 -1
  34. package/dist/registry/package/components/Drawer/index.tsx +16 -6
  35. package/dist/registry/package/components/DropdownMenu/index.tsx +37 -4
  36. package/dist/registry/package/components/DropdownMenu/styles.css +10 -0
  37. package/dist/registry/package/components/DurationDisplay/index.tsx +20 -2
  38. package/dist/registry/package/components/EntityMeta/index.tsx +14 -9
  39. package/dist/registry/package/components/ErrorState/index.tsx +9 -3
  40. package/dist/registry/package/components/ErrorState/styles.css +1 -1
  41. package/dist/registry/package/components/EventCalendar/index.tsx +657 -9
  42. package/dist/registry/package/components/EventCalendar/styles.css +360 -8
  43. package/dist/registry/package/components/EventManager/index.tsx +456 -0
  44. package/dist/registry/package/components/EventManager/styles.css +12 -0
  45. package/dist/registry/package/components/Field/index.tsx +74 -27
  46. package/dist/registry/package/components/FileUpload/index.tsx +13 -3
  47. package/dist/registry/package/components/FiltersBar/index.tsx +8 -3
  48. package/dist/registry/package/components/FiltersBar/styles.css +7 -0
  49. package/dist/registry/package/components/Form/index.tsx +118 -14
  50. package/dist/registry/package/components/Form/styles.css +3 -0
  51. package/dist/registry/package/components/HoverCard/index.tsx +8 -4
  52. package/dist/registry/package/components/Icons/index.tsx +18 -0
  53. package/dist/registry/package/components/ImageUpload/index.tsx +17 -2
  54. package/dist/registry/package/components/Input/index.tsx +3 -2
  55. package/dist/registry/package/components/JobProgress/index.tsx +289 -0
  56. package/dist/registry/package/components/JobProgress/styles.css +58 -0
  57. package/dist/registry/package/components/JsonEditor/index.tsx +18 -3
  58. package/dist/registry/package/components/JsonView/index.tsx +282 -0
  59. package/dist/registry/package/components/JsonView/styles.css +99 -0
  60. package/dist/registry/package/components/LiveIndicator/index.tsx +43 -0
  61. package/dist/registry/package/components/LiveIndicator/styles.css +41 -0
  62. package/dist/registry/package/components/LogStream/index.tsx +545 -0
  63. package/dist/registry/package/components/LogStream/styles.css +152 -0
  64. package/dist/registry/package/components/MarkdownEditor/index.tsx +14 -3
  65. package/dist/registry/package/components/Modal/index.tsx +7 -1
  66. package/dist/registry/package/components/MultiSelect/index.tsx +14 -3
  67. package/dist/registry/package/components/OtpInput/index.tsx +2 -1
  68. package/dist/registry/package/components/Pagination/index.tsx +13 -2
  69. package/dist/registry/package/components/PasswordInput/index.tsx +23 -9
  70. package/dist/registry/package/components/PhoneInput/index.tsx +66 -30
  71. package/dist/registry/package/components/Popover/index.tsx +19 -4
  72. package/dist/registry/package/components/QrCode/index.tsx +3 -2
  73. package/dist/registry/package/components/RadioGroup/styles.css +3 -0
  74. package/dist/registry/package/components/Rating/index.tsx +6 -2
  75. package/dist/registry/package/components/ReferenceSelect/index.tsx +8 -1
  76. package/dist/registry/package/components/RelativeTime/index.tsx +5 -1
  77. package/dist/registry/package/components/RequireSession/index.tsx +10 -4
  78. package/dist/registry/package/components/Resource/index.tsx +70 -17
  79. package/dist/registry/package/components/RichTextEditor/index.tsx +2 -1
  80. package/dist/registry/package/components/SearchInput/index.tsx +4 -1
  81. package/dist/registry/package/components/SecretFields/index.tsx +70 -17
  82. package/dist/registry/package/components/Select/index.tsx +44 -12
  83. package/dist/registry/package/components/Select/styles.css +6 -3
  84. package/dist/registry/package/components/Sidebar/index.tsx +22 -6
  85. package/dist/registry/package/components/Slider/index.tsx +26 -5
  86. package/dist/registry/package/components/Spinner/index.tsx +3 -2
  87. package/dist/registry/package/components/StatCard/index.tsx +11 -3
  88. package/dist/registry/package/components/Statistic/index.tsx +5 -1
  89. package/dist/registry/package/components/Steps/styles.css +2 -2
  90. package/dist/registry/package/components/TagsInput/index.tsx +2 -1
  91. package/dist/registry/package/components/TimePicker/index.tsx +10 -4
  92. package/dist/registry/package/components/Toast/index.tsx +20 -5
  93. package/dist/registry/package/components/Tour/index.tsx +2 -1
  94. package/dist/registry/package/components/Transfer/index.tsx +2 -1
  95. package/dist/registry/package/components/Tree/index.tsx +3 -2
  96. package/dist/registry/package/components/TreeSelect/index.tsx +17 -4
  97. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +34 -3
  98. package/dist/registry/registry.json +257 -75
  99. package/dist/registry/styles/sia-ui.css +23 -4
  100. package/package.json +5 -5
@@ -0,0 +1,545 @@
1
+ import {
2
+ isValidElement,
3
+ memo,
4
+ useDeferredValue,
5
+ useId,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { cn } from "@sia-ui/utils";
13
+ import { plural, useSiaLocale, type LogStreamMessages } from "@sia-ui/headless";
14
+ import { useControllableState, useCopyToClipboard } from "@sia-ui/react";
15
+ import { Button, type ButtonProps } from "../Button";
16
+ import { EmptyState, type EmptyStateProps } from "../EmptyState";
17
+ import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "../Icons";
18
+ import { SearchInput, type SearchInputProps } from "../SearchInput";
19
+ import { Spinner, type SpinnerProps } from "../Spinner";
20
+ import {
21
+ Toggle,
22
+ ToggleGroup,
23
+ type ToggleGroupProps,
24
+ type ToggleProps,
25
+ } from "../Toggle";
26
+ import "./styles.css";
27
+
28
+ /** La gravité d'une ligne. */
29
+ export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
30
+
31
+ /** Une ligne de journal. */
32
+ export interface LogLine {
33
+ /**
34
+ * Un identifiant stable. Facultatif, mais conseillé : sans lui, la ligne
35
+ * est repérée par sa position, et un tableau raccourci par le début
36
+ * obligerait à redessiner toutes les lignes.
37
+ */
38
+ id?: string;
39
+ /** Quand : une date ISO, un horodatage ou une `Date`. */
40
+ time: string | number | Date;
41
+ level: LogLevel;
42
+ message: ReactNode;
43
+ /** L'essai, l'étape ou la tâche : « Essai 1 », « Essai 2 ». */
44
+ group?: string;
45
+ }
46
+
47
+ /** Les niveaux, dans l'ordre de la barre de filtres. */
48
+ const NIVEAUX: LogLevel[] = ["debug", "info", "warn", "error", "success"];
49
+
50
+ /**
51
+ * Les libellés par défaut, lus dans la locale. Courts et de longueur
52
+ * voisine : ils tiennent dans une colonne à largeur fixe et restent lisibles
53
+ * une fois copiés en texte.
54
+ */
55
+ function libellesNiveaux(m: LogStreamMessages): Record<LogLevel, string> {
56
+ return {
57
+ debug: m.levelDebug,
58
+ info: m.levelInfo,
59
+ warn: m.levelWarn,
60
+ error: m.levelError,
61
+ success: m.levelSuccess,
62
+ };
63
+ }
64
+
65
+ /** En pixels : la marge sous laquelle on considère la zone « en bas ». */
66
+ const SEUIL_BAS = 16;
67
+
68
+ export interface LogStreamProps {
69
+ lines: LogLine[];
70
+ /**
71
+ * Le nombre de lignes gardées. Au-delà, les plus anciennes tombent.
72
+ *
73
+ * C'est la réponse à un journal qui grossit sans fin, plutôt qu'une
74
+ * virtualisation : mille lignes se dessinent sans peine, et une liste
75
+ * virtuelle casserait la recherche du navigateur et la sélection au
76
+ * clavier. `1000` par défaut.
77
+ */
78
+ maxLines?: number;
79
+ /** La hauteur de la zone défilante. `20rem` par défaut. */
80
+ height?: number | string;
81
+
82
+ /**
83
+ * Défiler jusqu'à la dernière ligne à chaque arrivée. Contrôlé quand il
84
+ * est fourni ; remonter dans le journal appelle alors `onFollowChange(false)`.
85
+ */
86
+ follow?: boolean;
87
+ /** Vrai par défaut : un journal d'exécution se lit par la fin. */
88
+ defaultFollow?: boolean;
89
+ onFollowChange?: (follow: boolean) => void;
90
+
91
+ /** Les niveaux affichés. Contrôlé quand il est fourni. */
92
+ levels?: LogLevel[];
93
+ /** Tous par défaut. */
94
+ defaultLevels?: LogLevel[];
95
+ onLevelsChange?: (levels: LogLevel[]) => void;
96
+ /** Les libellés des niveaux, dans la colonne comme dans la copie. */
97
+ levelLabels?: Partial<Record<LogLevel, string>>;
98
+
99
+ /** Les groupes repliés au premier affichage. */
100
+ defaultCollapsedGroups?: string[];
101
+
102
+ /** Le premier chargement : rien n'est encore arrivé. */
103
+ loading?: boolean;
104
+ /** Des lignes arrivent encore. Un indicateur discret le dit. */
105
+ streaming?: boolean;
106
+ streamingLabel?: string;
107
+ loadingLabel?: string;
108
+
109
+ /** La langue du format horaire. Par défaut, celle de la locale SIA. */
110
+ locale?: string;
111
+ /** Sans barre d'outils : ni filtre, ni recherche, ni copie. */
112
+ toolbar?: boolean;
113
+ /** Le nom de la zone pour les lecteurs d'écran. */
114
+ ariaLabel?: string;
115
+ searchPlaceholder?: string;
116
+ copyLabel?: string;
117
+ copiedLabel?: string;
118
+ followLabel?: string;
119
+ resumeLabel?: string;
120
+ className?: string;
121
+
122
+ /**
123
+ * Le filtre par niveau. Sans ses options ni sa valeur, tirées des niveaux,
124
+ * ni son type : on en retient plusieurs à la fois.
125
+ */
126
+ levelFilterProps?: Partial<
127
+ Omit<ToggleGroupProps, "options" | "type" | "value" | "defaultValue" | "onValueChange">
128
+ >;
129
+ /** La recherche. Sa valeur reste celle du journal, qui filtre avec. */
130
+ searchInputProps?: Partial<Omit<SearchInputProps, "value" | "defaultValue" | "onValueChange">>;
131
+ /** Le bouton de copie. Son action et son libellé suivent la copie. */
132
+ copyButtonProps?: Partial<Omit<ButtonProps, "onClick" | "children">>;
133
+ /** La bascule « Suivre la fin ». Son état est celui du suivi. */
134
+ followToggleProps?: Partial<Omit<ToggleProps, "pressed" | "defaultPressed" | "onPressedChange">>;
135
+ /** Le bouton « Reprendre le suivi », affiché quand le suivi est en pause. */
136
+ resumeButtonProps?: Partial<Omit<ButtonProps, "onClick" | "children">>;
137
+ /**
138
+ * L'en-tête repliable de chaque groupe. `aria-expanded` et
139
+ * `aria-controls` restent calculés groupe par groupe.
140
+ */
141
+ groupButtonProps?: Partial<
142
+ Omit<ButtonProps, "onClick" | "children" | "aria-expanded" | "aria-controls">
143
+ >;
144
+ /** L'état vide : pas encore de ligne, ou aucune qui corresponde. */
145
+ emptyStateProps?: Partial<EmptyStateProps>;
146
+ /** Les indicateurs de chargement et de flux en cours. Sans `label`. */
147
+ spinnerProps?: Partial<Omit<SpinnerProps, "label">>;
148
+ }
149
+
150
+ /** Une ligne retenue, avec sa clé et sa position parmi les lignes gardées. */
151
+ interface Entree {
152
+ line: LogLine;
153
+ key: string;
154
+ pos: number;
155
+ }
156
+
157
+ /**
158
+ * Le texte d'un message, pour la recherche et la copie.
159
+ *
160
+ * Un message est un nœud React : on descend dans les enfants pour en tirer
161
+ * le texte. Le résultat est gardé par ligne — une ligne ne change pas une
162
+ * fois émise, et refaire ce parcours à chaque frappe sur mille lignes se
163
+ * sentirait.
164
+ */
165
+ const textes = new WeakMap<LogLine, { brut: string; bas: string }>();
166
+
167
+ function texteDe(node: ReactNode): string {
168
+ if (node === null || node === undefined || typeof node === "boolean") return "";
169
+ if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
170
+ return String(node);
171
+ }
172
+ if (Array.isArray(node)) return node.map((n: ReactNode) => texteDe(n)).join("");
173
+ if (isValidElement<{ children?: ReactNode }>(node)) return texteDe(node.props.children);
174
+ return "";
175
+ }
176
+
177
+ function texteLigne(line: LogLine) {
178
+ let t = textes.get(line);
179
+ if (!t) {
180
+ const brut = texteDe(line.message);
181
+ t = { brut, bas: brut.toLowerCase() };
182
+ textes.set(line, t);
183
+ }
184
+ return t;
185
+ }
186
+
187
+ function heure(format: Intl.DateTimeFormat, time: LogLine["time"]) {
188
+ const date = time instanceof Date ? time : new Date(time);
189
+ return Number.isNaN(date.getTime()) ? "--:--:--" : format.format(date);
190
+ }
191
+
192
+ interface LigneProps {
193
+ line: LogLine;
194
+ format: Intl.DateTimeFormat;
195
+ label: string;
196
+ }
197
+
198
+ /**
199
+ * Une ligne. Mémoïsée : quand une ligne arrive, seules les nouvelles se
200
+ * dessinent, les mille précédentes gardent leur rendu.
201
+ */
202
+ const Ligne = memo(function Ligne({ line, format, label }: LigneProps) {
203
+ const date = line.time instanceof Date ? line.time : new Date(line.time);
204
+ const valide = !Number.isNaN(date.getTime());
205
+ return (
206
+ <div className={cn("sia-log-stream__line", `sia-log-stream__line--${line.level}`)}>
207
+ <time
208
+ className="sia-log-stream__time"
209
+ {...(valide ? { dateTime: date.toISOString() } : {})}
210
+ >
211
+ {heure(format, line.time)}
212
+ </time>
213
+ {/*
214
+ Un marqueur texte, pas un `Badge` : il tient dans la colonne à chasse
215
+ fixe, se copie tel quel, et ne coûte pas un composant par ligne.
216
+ */}
217
+ <span className="sia-log-stream__level">{label}</span>
218
+ <span className="sia-log-stream__message">{line.message}</span>
219
+ </div>
220
+ );
221
+ });
222
+
223
+ /**
224
+ * Un journal d'exécution qui se lit en direct.
225
+ *
226
+ * `Timeline` raconte des événements ; ici, on suit une exécution : heure en
227
+ * colonne, niveau, police à chasse fixe, essais repliables. Le suivi de la
228
+ * fin se met en pause dès qu'on remonte — lire une erreur pendant que le
229
+ * journal défile sous les yeux est impossible — et un bouton dit combien de
230
+ * lignes sont arrivées entre-temps.
231
+ */
232
+ export function LogStream({
233
+ lines,
234
+ maxLines = 1000,
235
+ height = "20rem",
236
+ follow,
237
+ defaultFollow = true,
238
+ onFollowChange,
239
+ levels: levelsProp,
240
+ defaultLevels = NIVEAUX,
241
+ onLevelsChange,
242
+ levelLabels,
243
+ defaultCollapsedGroups,
244
+ loading = false,
245
+ streaming = false,
246
+ streamingLabel: streamingLabelProp,
247
+ loadingLabel: loadingLabelProp,
248
+ locale: localeProp,
249
+ toolbar = true,
250
+ ariaLabel: ariaLabelProp,
251
+ searchPlaceholder: searchPlaceholderProp,
252
+ copyLabel: copyLabelProp,
253
+ copiedLabel: copiedLabelProp,
254
+ followLabel: followLabelProp,
255
+ resumeLabel: resumeLabelProp,
256
+ className,
257
+ levelFilterProps,
258
+ searchInputProps,
259
+ copyButtonProps,
260
+ followToggleProps,
261
+ resumeButtonProps,
262
+ groupButtonProps,
263
+ emptyStateProps,
264
+ spinnerProps,
265
+ }: LogStreamProps) {
266
+ const messages = useSiaLocale();
267
+ const m = messages.logStream;
268
+ const locale = localeProp ?? messages.language;
269
+ const streamingLabel = streamingLabelProp ?? m.streaming;
270
+ const loadingLabel = loadingLabelProp ?? m.loading;
271
+ const ariaLabel = ariaLabelProp ?? m.ariaLabel;
272
+ const searchPlaceholder = searchPlaceholderProp ?? m.searchPlaceholder;
273
+ const copyLabel = copyLabelProp ?? messages.copy;
274
+ const copiedLabel = copiedLabelProp ?? messages.copied;
275
+ const followLabel = followLabelProp ?? m.follow;
276
+ const resumeLabel = resumeLabelProp ?? m.resume;
277
+ const id = useId();
278
+ const zone = useRef<HTMLDivElement>(null);
279
+ const { copy, copied } = useCopyToClipboard();
280
+
281
+ const [following, setFollowing] = useControllableState({
282
+ value: follow,
283
+ defaultValue: defaultFollow,
284
+ onChange: onFollowChange,
285
+ });
286
+ const [levels, setLevels] = useControllableState<LogLevel[]>({
287
+ value: levelsProp,
288
+ defaultValue: defaultLevels,
289
+ onChange: onLevelsChange,
290
+ });
291
+ const [search, setSearch] = useState("");
292
+ const [collapsed, setCollapsed] = useState(() => new Set(defaultCollapsedGroups));
293
+
294
+ // La frappe reste immédiate ; le filtrage de mille lignes passe après.
295
+ const needle = useDeferredValue(search).trim().toLowerCase();
296
+
297
+ const labels = useMemo(() => ({ ...libellesNiveaux(m), ...levelLabels }), [m, levelLabels]);
298
+
299
+ // Un seul format pour tout le journal : `Intl.DateTimeFormat` coûte cher à
300
+ // construire, pas à utiliser.
301
+ const format = useMemo(
302
+ () =>
303
+ new Intl.DateTimeFormat(locale, {
304
+ hour: "2-digit",
305
+ minute: "2-digit",
306
+ second: "2-digit",
307
+ hourCycle: "h23",
308
+ }),
309
+ [locale],
310
+ );
311
+
312
+ // Les lignes gardées, avec une clé stable : l'identifiant, sinon la
313
+ // position dans le tableau complet — elle ne bouge pas tant qu'on ajoute
314
+ // à la fin.
315
+ const kept = useMemo(() => {
316
+ const debut = Math.max(0, lines.length - Math.max(0, maxLines));
317
+ const out: Entree[] = [];
318
+ for (let i = debut; i < lines.length; i++) {
319
+ const line = lines[i]!;
320
+ out.push({ line, key: line.id ?? `#${i}`, pos: i - debut });
321
+ }
322
+ return out;
323
+ }, [lines, maxLines]);
324
+
325
+ const levelSet = useMemo(() => new Set(levels), [levels]);
326
+
327
+ const visible = useMemo(
328
+ () =>
329
+ kept.filter(
330
+ ({ line }) =>
331
+ levelSet.has(line.level) && (!needle || texteLigne(line).bas.includes(needle)),
332
+ ),
333
+ [kept, levelSet, needle],
334
+ );
335
+
336
+ // Les groupes, dans l'ordre de leur première ligne. Les lignes sans groupe
337
+ // forment une section sans en-tête.
338
+ const sections = useMemo(() => {
339
+ const map = new Map<string, Entree[]>();
340
+ for (const entree of visible) {
341
+ const g = entree.line.group ?? "";
342
+ const list = map.get(g);
343
+ if (list) list.push(entree);
344
+ else map.set(g, [entree]);
345
+ }
346
+ return [...map];
347
+ }, [visible]);
348
+
349
+ // Le repère de la pause : la dernière ligne vue au moment où l'on a
350
+ // remonté. Ajusté pendant le rendu, comme le recommande React pour un état
351
+ // dérivé, plutôt que dans un effet qui dessinerait une image de trop.
352
+ const derniere = kept.length > 0 ? kept[kept.length - 1]!.key : "";
353
+ const [repere, setRepere] = useState<string | null>(following ? null : derniere);
354
+ if (following && repere !== null) setRepere(null);
355
+ if (!following && repere === null) setRepere(derniere);
356
+
357
+ const nouvelles = useMemo(() => {
358
+ if (following || repere === null) return 0;
359
+ let position = -1;
360
+ for (let i = kept.length - 1; i >= 0; i--) {
361
+ if (kept[i]!.key === repere) {
362
+ position = kept[i]!.pos;
363
+ break;
364
+ }
365
+ }
366
+ let n = 0;
367
+ for (let i = visible.length - 1; i >= 0 && visible[i]!.pos > position; i--) n++;
368
+ return n;
369
+ }, [following, repere, kept, visible]);
370
+
371
+ // Avant la peinture : l'utilisateur ne voit jamais la ligne apparaître
372
+ // puis la zone sauter.
373
+ const derniereVisible = visible.length > 0 ? visible[visible.length - 1]!.key : "";
374
+ useLayoutEffect(() => {
375
+ const node = zone.current;
376
+ if (following && node) node.scrollTop = node.scrollHeight;
377
+ }, [following, derniereVisible, visible.length, collapsed]);
378
+
379
+ const onScroll = () => {
380
+ const node = zone.current;
381
+ if (!node) return;
382
+ const enBas = node.scrollHeight - node.scrollTop - node.clientHeight <= SEUIL_BAS;
383
+ // Remonter met en pause ; revenir tout en bas reprend le suivi.
384
+ if (enBas !== following) setFollowing(enBas);
385
+ };
386
+
387
+ const basculer = (groupe: string) =>
388
+ setCollapsed((prev) => {
389
+ const next = new Set(prev);
390
+ if (next.has(groupe)) next.delete(groupe);
391
+ else next.add(groupe);
392
+ return next;
393
+ });
394
+
395
+ // Construit au clic seulement : personne ne paie la copie qu'il ne fait pas.
396
+ const copier = () => {
397
+ const out: string[] = [];
398
+ for (const [groupe, entrees] of sections) {
399
+ if (groupe) out.push(`[${groupe}]`);
400
+ for (const { line } of entrees) {
401
+ out.push(`${heure(format, line.time)} ${labels[line.level]} ${texteLigne(line).brut}`);
402
+ }
403
+ }
404
+ void copy(out.join("\n"));
405
+ };
406
+
407
+ const vide = kept.length === 0;
408
+
409
+ let contenu: ReactNode;
410
+ if (vide && loading) {
411
+ contenu = (
412
+ <div className="sia-log-stream__loading">
413
+ <Spinner size="sm" {...spinnerProps} label={loadingLabel} />
414
+ </div>
415
+ );
416
+ } else if (visible.length === 0) {
417
+ contenu = (
418
+ <EmptyState
419
+ compact
420
+ {...(vide
421
+ ? { title: m.emptyTitle }
422
+ : {
423
+ title: m.noMatchTitle,
424
+ description: m.noMatchDescription,
425
+ })}
426
+ {...emptyStateProps}
427
+ />
428
+ );
429
+ } else {
430
+ contenu = sections.map(([groupe, entrees], index) => {
431
+ const rendu = entrees.map(({ line, key }) => (
432
+ <Ligne key={key} line={line} format={format} label={labels[line.level]} />
433
+ ));
434
+ if (!groupe) return <div key="">{rendu}</div>;
435
+ const ouvert = !collapsed.has(groupe);
436
+ const corps = `${id}-groupe-${index}`;
437
+ return (
438
+ <div key={`g:${groupe}`} className="sia-log-stream__group">
439
+ <Button
440
+ variant="ghost"
441
+ size="sm"
442
+ leftIcon={ouvert ? <ChevronDownIcon /> : <ChevronRightIcon />}
443
+ {...groupButtonProps}
444
+ className={cn("sia-log-stream__group-header", groupButtonProps?.className)}
445
+ aria-expanded={ouvert}
446
+ aria-controls={corps}
447
+ onClick={() => basculer(groupe)}
448
+ >
449
+ {groupe}
450
+ <span className="sia-log-stream__group-count">
451
+ {plural(m, "lines", entrees.length, locale)}
452
+ </span>
453
+ </Button>
454
+ <div id={corps} hidden={!ouvert}>
455
+ {ouvert && rendu}
456
+ </div>
457
+ </div>
458
+ );
459
+ });
460
+ }
461
+
462
+ return (
463
+ <div className={cn("sia-log-stream", className)}>
464
+ {toolbar && (
465
+ <div className="sia-log-stream__toolbar">
466
+ <ToggleGroup
467
+ size="md"
468
+ ariaLabel={m.levelsLabel}
469
+ {...levelFilterProps}
470
+ className={cn("sia-log-stream__levels", levelFilterProps?.className)}
471
+ type="multiple"
472
+ options={NIVEAUX.map((niveau) => ({ value: niveau, label: labels[niveau] }))}
473
+ value={levels}
474
+ onValueChange={(next) => setLevels((Array.isArray(next) ? next : [next]) as LogLevel[])}
475
+ />
476
+ <SearchInput
477
+ placeholder={searchPlaceholder}
478
+ {...searchInputProps}
479
+ className={cn("sia-log-stream__search", searchInputProps?.className)}
480
+ value={search}
481
+ onValueChange={setSearch}
482
+ />
483
+ <div className="sia-log-stream__actions">
484
+ {streaming && (
485
+ <span className="sia-log-stream__streaming">
486
+ <Spinner size="xs" {...spinnerProps} label={streamingLabel} />
487
+ <span aria-hidden="true">{streamingLabel}</span>
488
+ </span>
489
+ )}
490
+ <Toggle
491
+ size="md"
492
+ variant="outline"
493
+ {...followToggleProps}
494
+ pressed={following}
495
+ onPressedChange={setFollowing}
496
+ >
497
+ {followLabel}
498
+ </Toggle>
499
+ <Button
500
+ variant="outline"
501
+ size="md"
502
+ {...(copied ? { leftIcon: <CheckIcon /> } : {})}
503
+ disabled={visible.length === 0}
504
+ {...copyButtonProps}
505
+ onClick={copier}
506
+ >
507
+ {copied ? copiedLabel : copyLabel}
508
+ </Button>
509
+ </div>
510
+ </div>
511
+ )}
512
+ <div className="sia-log-stream__viewport">
513
+ {/*
514
+ Une `div` et non `ScrollArea` : le suivi lit et règle la position de
515
+ défilement, et `ScrollArea` n'expose ni sa référence ni son
516
+ événement de défilement.
517
+ */}
518
+ <div
519
+ ref={zone}
520
+ className="sia-log-stream__scroll"
521
+ style={{ height }}
522
+ role="log"
523
+ aria-live="polite"
524
+ aria-label={ariaLabel}
525
+ aria-busy={loading || undefined}
526
+ tabIndex={0}
527
+ onScroll={onScroll}
528
+ >
529
+ {contenu}
530
+ </div>
531
+ {!following && (
532
+ <Button
533
+ size="sm"
534
+ {...resumeButtonProps}
535
+ className={cn("sia-log-stream__resume", resumeButtonProps?.className)}
536
+ onClick={() => setFollowing(true)}
537
+ >
538
+ {resumeLabel}
539
+ {nouvelles > 0 && plural(m, "newLines", nouvelles, locale)}
540
+ </Button>
541
+ )}
542
+ </div>
543
+ </div>
544
+ );
545
+ }
@@ -0,0 +1,152 @@
1
+ /* Un journal se lit en colonnes : heure, niveau, message. La chasse fixe
2
+ garde les colonnes alignées d'une ligne à l'autre, et un message long
3
+ passe à la ligne sous lui-même sans déborder sous l'heure. */
4
+ .sia-log-stream {
5
+ container: sia-log-stream / inline-size;
6
+ display: grid;
7
+ gap: .5rem;
8
+ min-width: 0;
9
+ }
10
+
11
+ .sia-log-stream__toolbar {
12
+ display: flex;
13
+ flex-wrap: wrap;
14
+ align-items: center;
15
+ gap: .5rem;
16
+ }
17
+
18
+ .sia-log-stream__search { flex: 1 1 12rem; min-width: 0; }
19
+
20
+ /* Étirés : la bascule, moins haute que les boutons à taille égale, prend la
21
+ hauteur de la barre et s'aligne sur la recherche. */
22
+ .sia-log-stream__actions {
23
+ display: flex;
24
+ flex-wrap: wrap;
25
+ align-items: stretch;
26
+ gap: .5rem;
27
+ margin-inline-start: auto;
28
+ }
29
+
30
+ .sia-log-stream__streaming {
31
+ align-self: center;
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: .375rem;
35
+ color: var(--sia-muted);
36
+ font-size: .8rem;
37
+ }
38
+
39
+ .sia-log-stream__viewport { position: relative; min-width: 0; }
40
+
41
+ .sia-log-stream__scroll {
42
+ overflow: auto;
43
+ overscroll-behavior: contain;
44
+ padding: .375rem 0;
45
+ border: 1px solid var(--sia-border);
46
+ border-radius: var(--sia-radius);
47
+ color: var(--sia-foreground);
48
+ background: var(--sia-surface);
49
+ }
50
+
51
+ .sia-log-stream__scroll:focus-visible {
52
+ outline: 2px solid color-mix(in srgb, var(--sia-primary) 45%, transparent);
53
+ outline-offset: 2px;
54
+ }
55
+
56
+ /* Heure et niveau en `ch` : leur largeur ne dépend que du nombre de
57
+ caractères, jamais du contenu d'une ligne. */
58
+ .sia-log-stream__line {
59
+ display: grid;
60
+ grid-template-columns: 8ch 6ch minmax(0, 1fr);
61
+ column-gap: 1.25ch;
62
+ padding: .0625rem .75rem;
63
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
64
+ font-size: .8rem;
65
+ line-height: 1.5;
66
+ }
67
+
68
+ .sia-log-stream__line:hover { background: color-mix(in srgb, var(--sia-foreground) 4%, transparent); }
69
+
70
+ .sia-log-stream__time { color: var(--sia-muted); font-variant-numeric: tabular-nums; }
71
+
72
+ .sia-log-stream__level { font-weight: 600; }
73
+
74
+ .sia-log-stream__message {
75
+ min-width: 0;
76
+ white-space: pre-wrap;
77
+ overflow-wrap: anywhere;
78
+ }
79
+
80
+ .sia-log-stream__line--debug .sia-log-stream__level,
81
+ .sia-log-stream__line--debug .sia-log-stream__message { color: var(--sia-muted); }
82
+ .sia-log-stream__line--info .sia-log-stream__level { color: var(--sia-info); }
83
+ .sia-log-stream__line--success .sia-log-stream__level { color: var(--sia-success); }
84
+ .sia-log-stream__line--warn .sia-log-stream__level { color: var(--sia-warning); }
85
+ .sia-log-stream__line--error .sia-log-stream__level { color: var(--sia-danger); }
86
+
87
+ /* Un fond léger sur ce qui demande l'œil : on retrouve une erreur en
88
+ parcourant, sans lire chaque niveau. */
89
+ .sia-log-stream__line--warn { background: color-mix(in srgb, var(--sia-warning) 7%, transparent); }
90
+ .sia-log-stream__line--error { background: color-mix(in srgb, var(--sia-danger) 8%, transparent); }
91
+
92
+ .sia-log-stream__group:not(:first-child) {
93
+ margin-top: .25rem;
94
+ border-top: 1px solid var(--sia-border);
95
+ }
96
+
97
+ .sia-log-stream__group-header.sia-log-stream__group-header {
98
+ width: 100%;
99
+ justify-content: flex-start;
100
+ border-radius: 0;
101
+ font-weight: 600;
102
+ }
103
+
104
+ .sia-log-stream__group-count {
105
+ margin-inline-start: .75ch;
106
+ color: var(--sia-muted);
107
+ font-weight: 400;
108
+ }
109
+
110
+ .sia-log-stream__loading {
111
+ display: grid;
112
+ place-items: center;
113
+ min-height: 8rem;
114
+ color: var(--sia-muted);
115
+ }
116
+
117
+ .sia-log-stream__scroll .sia-empty-state { margin: .375rem .75rem; }
118
+
119
+ /* Posé sur la zone, en bas : il est là où l'œil attend la suite. */
120
+ .sia-log-stream__resume.sia-log-stream__resume {
121
+ position: absolute;
122
+ bottom: .75rem;
123
+ left: 50%;
124
+ translate: -50% 0;
125
+ box-shadow: var(--sia-elevation-overlay);
126
+ animation: sia-log-stream-resume var(--sia-duration-fast) var(--sia-easing-entrance);
127
+ }
128
+
129
+ @keyframes sia-log-stream-resume {
130
+ from { opacity: 0; translate: -50% .5rem; }
131
+ }
132
+
133
+ @container sia-log-stream (max-width: 30rem) {
134
+ .sia-log-stream__line { grid-template-columns: 8ch minmax(0, 1fr); }
135
+ /* En étroit, le niveau passe sous l'heure : le message garde la largeur. */
136
+ .sia-log-stream__level { grid-column: 1; grid-row: 2; font-size: .7rem; }
137
+ .sia-log-stream__message { grid-column: 2; grid-row: 1 / span 2; }
138
+ .sia-log-stream__actions { margin-inline-start: 0; }
139
+ /* Les cinq niveaux se partagent la largeur plutôt que de déborder. */
140
+ .sia-log-stream__levels.sia-log-stream__levels { display: flex; width: 100%; }
141
+ .sia-log-stream__levels .sia-toggle-group__item {
142
+ flex: 1 1 0;
143
+ min-width: 0;
144
+ justify-content: center;
145
+ padding-inline: .25rem;
146
+ font-size: .75rem;
147
+ }
148
+ }
149
+
150
+ @media (prefers-reduced-motion: reduce) {
151
+ .sia-log-stream__resume.sia-log-stream__resume { animation: none; }
152
+ }