@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,289 @@
1
+ import { useEffect, useState, type HTMLAttributes, type ReactNode } from "react";
2
+ import { cn } from "@sia-ui/utils";
3
+ import type { ComponentTone } from "@sia-ui/tokens";
4
+ import { formatMessage, useSiaLocale, type SiaLocale } from "@sia-ui/headless";
5
+ import { Alert, type AlertProps } from "../Alert";
6
+ import { StatusBadge, type StatusBadgeProps } from "../Badge";
7
+ import { Button, type ButtonProps } from "../Button";
8
+ import { DurationDisplay, type DurationDisplayProps } from "../DurationDisplay";
9
+ import { Progress, type ProgressProps } from "../Progress";
10
+ import { Steps, type StepItem, type StepsProps } from "../Steps";
11
+ import "./styles.css";
12
+
13
+ /** Le cycle de vie d'une opération asynchrone. */
14
+ export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled";
15
+
16
+ /** Un instant : une date ISO, un horodatage ou une `Date`. */
17
+ export type JobInstant = string | number | Date;
18
+
19
+ /**
20
+ * Les textes du composant, à remplacer au cas par cas. Leur valeur par défaut
21
+ * vient du groupe `jobProgress` de la locale (`JobProgressMessages`) ; ici,
22
+ * un texte peut aussi être un nœud, et `attempts` une fonction.
23
+ */
24
+ export interface JobProgressLabels {
25
+ queued: ReactNode;
26
+ running: ReactNode;
27
+ succeeded: ReactNode;
28
+ failed: ReactNode;
29
+ cancelled: ReactNode;
30
+ /** « Essai 2 sur 3 », ou « Essai 2 » sans maximum connu. */
31
+ attempts: (current: number, max?: number) => ReactNode;
32
+ /** Le titre de l'encadré d'erreur. */
33
+ errorTitle: ReactNode;
34
+ retry: ReactNode;
35
+ cancel: ReactNode;
36
+ }
37
+
38
+ /** Les libellés par défaut, tirés de la locale. */
39
+ function libelles(locale: SiaLocale): JobProgressLabels {
40
+ const m = locale.jobProgress;
41
+ return {
42
+ queued: m.queued,
43
+ running: m.running,
44
+ succeeded: m.succeeded,
45
+ failed: m.failed,
46
+ cancelled: m.cancelled,
47
+ attempts: (current, max) =>
48
+ max === undefined
49
+ ? formatMessage(m.attempt, { current })
50
+ : formatMessage(m.attemptOf, { current, max }),
51
+ errorTitle: m.errorTitle,
52
+ retry: m.retry,
53
+ cancel: locale.cancel,
54
+ };
55
+ }
56
+
57
+ const TONS: Record<JobStatus, ComponentTone> = {
58
+ queued: "neutral",
59
+ running: "primary",
60
+ succeeded: "success",
61
+ failed: "danger",
62
+ cancelled: "warning",
63
+ };
64
+
65
+ export interface JobProgressProps
66
+ extends Omit<HTMLAttributes<HTMLElement>, "title"> {
67
+ status: JobStatus;
68
+ /** L'avancement, de 0 à 100. Absent pendant `queued`/`running` : indéterminé. */
69
+ progress?: number;
70
+ /** L'étape en cours : « Construction de l'image ». */
71
+ step?: ReactNode;
72
+ /** Les étapes de l'opération, affichées en liste ; `currentStep` la met en avant. */
73
+ steps?: StepItem[];
74
+ /** L'index de l'étape en cours dans `steps`. */
75
+ currentStep?: number;
76
+ /** L'essai en cours, et le nombre maximal quand il est connu. */
77
+ attempts?: { current: number; max?: number };
78
+ startedAt?: JobInstant;
79
+ /** Absent pendant `running` : la durée avance chaque seconde. */
80
+ finishedAt?: JobInstant;
81
+ /** Le message d'échec, affiché quand `status` vaut `failed`. */
82
+ error?: ReactNode;
83
+ /** Affiche « Relancer » quand l'opération a échoué. */
84
+ onRetry?: () => void;
85
+ /** Affiche « Annuler » tant que l'opération est en file ou en cours. */
86
+ onCancel?: () => void;
87
+ /** Un emplacement libre sous le reste : un journal, une sortie de commande. */
88
+ logs?: ReactNode;
89
+ title?: ReactNode;
90
+ labels?: Partial<JobProgressLabels>;
91
+ /** Le ton de chaque statut, pour la pastille et la barre. */
92
+ statusTones?: Partial<Record<JobStatus, ComponentTone>>;
93
+
94
+ /** Les props de la pastille de statut. Sans `value`, `tones` ni `labels`, tirés du statut. */
95
+ statusBadgeProps?: Partial<Omit<StatusBadgeProps, "value" | "tones" | "labels">>;
96
+ /** Les props de la barre. Sans `value`, tirée de `progress`. */
97
+ progressProps?: Partial<Omit<ProgressProps, "value">>;
98
+ /** Les props de la liste d'étapes. Sans `items` ni `current`. */
99
+ stepsProps?: Partial<Omit<StepsProps, "items" | "current">>;
100
+ /** Les props de la durée. Sans `milliseconds`, calculée des deux instants. */
101
+ durationProps?: Partial<Omit<DurationDisplayProps, "milliseconds">>;
102
+ /** Les props de l'encadré d'erreur. Sans `children`, qui est `error`. */
103
+ alertProps?: Partial<Omit<AlertProps, "children">>;
104
+ /** Les props du bouton « Relancer ». Sans `onClick`, qui est `onRetry`. */
105
+ retryButtonProps?: Partial<Omit<ButtonProps, "onClick">>;
106
+ /** Les props du bouton « Annuler ». Sans `onClick`, qui est `onCancel`. */
107
+ cancelButtonProps?: Partial<Omit<ButtonProps, "onClick">>;
108
+ }
109
+
110
+ function instant(value: JobInstant | undefined): number | undefined {
111
+ if (value === undefined) return undefined;
112
+ const time = new Date(value).getTime();
113
+ return Number.isNaN(time) ? undefined : time;
114
+ }
115
+
116
+ /**
117
+ * Le suivi d'une opération asynchrone : un déploiement, un import, un export.
118
+ *
119
+ * Statut, étape, progression, essais, erreur et relance : chaque projet
120
+ * recomposait ce motif avec les mêmes briques, et chacun oubliait une chose
121
+ * différente — la durée figée pendant l'exécution, le bouton d'annulation
122
+ * resté visible après l'échec. Le composant tranche ce qui s'affiche selon le
123
+ * statut ; l'appelant ne fournit que l'état de l'opération.
124
+ *
125
+ * Pensé pour un tiroir de 28 à 40rem : dense, sur une colonne.
126
+ */
127
+ export function JobProgress({
128
+ status,
129
+ progress,
130
+ step,
131
+ steps,
132
+ currentStep = 0,
133
+ attempts,
134
+ startedAt,
135
+ finishedAt,
136
+ error,
137
+ onRetry,
138
+ onCancel,
139
+ logs,
140
+ title,
141
+ labels: labelsProp,
142
+ statusTones,
143
+ statusBadgeProps,
144
+ progressProps,
145
+ stepsProps,
146
+ durationProps,
147
+ alertProps,
148
+ retryButtonProps,
149
+ cancelButtonProps,
150
+ className,
151
+ ...props
152
+ }: JobProgressProps) {
153
+ const locale = useSiaLocale();
154
+ const labels = { ...libelles(locale), ...labelsProp };
155
+ const statusLabels: Record<JobStatus, ReactNode> = {
156
+ queued: labels.queued,
157
+ running: labels.running,
158
+ succeeded: labels.succeeded,
159
+ failed: labels.failed,
160
+ cancelled: labels.cancelled,
161
+ };
162
+ const tones = { ...TONS, ...statusTones };
163
+ const active = status === "queued" || status === "running";
164
+
165
+ const debut = instant(startedAt);
166
+ const fin = instant(finishedAt);
167
+ // La durée d'une opération en cours avance d'elle-même : sans horloge, elle
168
+ // resterait figée à la valeur du dernier rendu, et l'on croirait l'opération
169
+ // bloquée. L'horloge ne tourne que tant qu'il y a quelque chose à compter.
170
+ const ticking = status === "running" && debut !== undefined && fin === undefined;
171
+ const [now, setNow] = useState(() => Date.now());
172
+ useEffect(() => {
173
+ if (!ticking) return;
174
+ setNow(Date.now());
175
+ const id = window.setInterval(() => setNow(Date.now()), 1000);
176
+ return () => window.clearInterval(id);
177
+ }, [ticking]);
178
+
179
+ const end = fin ?? (ticking ? now : undefined);
180
+ const duration = debut !== undefined && end !== undefined ? end - debut : undefined;
181
+
182
+ // Une opération terminée n'a de barre que si l'on sait où elle s'est
183
+ // arrêtée : une barre indéterminée après coup dirait « je travaille ».
184
+ const showProgress = active || progress !== undefined;
185
+
186
+ const stepItems =
187
+ steps && status === "failed"
188
+ ? steps.map((item, index) =>
189
+ index === currentStep && !item.status ? { ...item, status: "error" as const } : item,
190
+ )
191
+ : steps;
192
+ // Réussie, toutes les étapes sont faites, y compris la dernière.
193
+ const stepIndex = status === "succeeded" && steps ? steps.length : currentStep;
194
+
195
+ const canRetry = status === "failed" && onRetry !== undefined;
196
+ const canCancel = active && onCancel !== undefined;
197
+
198
+ return (
199
+ <section
200
+ className={cn("sia-job-progress", `sia-job-progress--${status}`, className)}
201
+ aria-busy={active || undefined}
202
+ {...props}
203
+ >
204
+ <header className="sia-job-progress__header">
205
+ {title && <div className="sia-job-progress__title">{title}</div>}
206
+ {/* Seule la pastille est une région vivante : y mettre la durée ferait
207
+ annoncer chaque seconde qui passe. */}
208
+ <span role="status" className="sia-job-progress__status">
209
+ <StatusBadge
210
+ {...statusBadgeProps}
211
+ value={status}
212
+ tones={tones}
213
+ labels={statusLabels}
214
+ />
215
+ </span>
216
+ {duration !== undefined && (
217
+ <DurationDisplay
218
+ style="clock"
219
+ {...durationProps}
220
+ className={cn("sia-job-progress__duration", durationProps?.className)}
221
+ milliseconds={duration}
222
+ />
223
+ )}
224
+ </header>
225
+
226
+ {(step || showProgress) && (
227
+ <div className="sia-job-progress__progress">
228
+ {showProgress ? (
229
+ <Progress
230
+ size="sm"
231
+ tone={tones[status]}
232
+ showValue
233
+ {...(step ? { label: step } : {})}
234
+ {...progressProps}
235
+ className={cn("sia-job-progress__bar", progressProps?.className)}
236
+ // Pas de `value` pendant la file ou l'exécution sans avancement
237
+ // connu : Progress passe alors en indéterminé.
238
+ {...(progress !== undefined ? { value: progress } : {})}
239
+ />
240
+ ) : (
241
+ <div className="sia-job-progress__step">{step}</div>
242
+ )}
243
+ </div>
244
+ )}
245
+
246
+ {stepItems && stepItems.length > 0 && (
247
+ <Steps
248
+ orientation="vertical"
249
+ size="sm"
250
+ {...stepsProps}
251
+ className={cn("sia-job-progress__steps", stepsProps?.className)}
252
+ items={stepItems}
253
+ current={stepIndex}
254
+ />
255
+ )}
256
+
257
+ {attempts && (
258
+ <div className="sia-job-progress__attempts">
259
+ {labels.attempts(attempts.current, attempts.max)}
260
+ </div>
261
+ )}
262
+
263
+ {status === "failed" && error && (
264
+ <Alert title={labels.errorTitle} {...alertProps} tone="danger">
265
+ {error}
266
+ </Alert>
267
+ )}
268
+
269
+ {(canRetry || canCancel) && (
270
+ <div className="sia-job-progress__actions">
271
+ {canCancel && (
272
+ <Button variant="outline" tone="neutral" size="sm" {...cancelButtonProps} onClick={onCancel}>
273
+ {cancelButtonProps?.children ?? labels.cancel}
274
+ </Button>
275
+ )}
276
+ {canRetry && (
277
+ <Button size="sm" {...retryButtonProps} onClick={onRetry}>
278
+ {retryButtonProps?.children ?? labels.retry}
279
+ </Button>
280
+ )}
281
+ </div>
282
+ )}
283
+
284
+ {logs !== undefined && logs !== null && (
285
+ <div className="sia-job-progress__logs">{logs}</div>
286
+ )}
287
+ </section>
288
+ );
289
+ }
@@ -0,0 +1,58 @@
1
+ /* Une seule colonne, un seul écart : le composant vit dans un tiroir de 28 à
2
+ 40rem, où deux colonnes se marcheraient dessus. */
3
+ .sia-job-progress {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: .75rem;
7
+ min-width: 0;
8
+ color: var(--sia-foreground);
9
+ font-size: .875rem;
10
+ }
11
+
12
+ /* Le titre prend la place qui reste ; pastille et durée restent collées à
13
+ droite, et passent dessous plutôt que d'écraser un titre long. */
14
+ .sia-job-progress__header {
15
+ display: flex;
16
+ flex-wrap: wrap;
17
+ align-items: center;
18
+ gap: .5rem;
19
+ min-width: 0;
20
+ }
21
+ .sia-job-progress__title {
22
+ flex: 1 1 12rem;
23
+ min-width: 0;
24
+ font-weight: 600;
25
+ overflow-wrap: anywhere;
26
+ }
27
+ .sia-job-progress__status { display: inline-flex; flex: none; }
28
+
29
+ /* Des chiffres de même largeur : la durée qui avance ne fait pas danser la
30
+ ligne à chaque seconde. */
31
+ .sia-job-progress__duration {
32
+ flex: none;
33
+ margin-left: auto;
34
+ color: var(--sia-muted);
35
+ font-size: .8rem;
36
+ font-variant-numeric: tabular-nums;
37
+ }
38
+
39
+ .sia-job-progress__progress { display: grid; gap: .35rem; min-width: 0; }
40
+ .sia-job-progress__bar .sia-progress__label { min-width: 0; overflow-wrap: anywhere; }
41
+ .sia-job-progress__step { color: var(--sia-muted); overflow-wrap: anywhere; }
42
+
43
+ .sia-job-progress__attempts {
44
+ color: var(--sia-muted);
45
+ font-size: .8rem;
46
+ font-variant-numeric: tabular-nums;
47
+ }
48
+
49
+ .sia-job-progress__actions {
50
+ display: flex;
51
+ flex-wrap: wrap;
52
+ justify-content: flex-end;
53
+ gap: .5rem;
54
+ }
55
+
56
+ /* Un journal est souvent large : sans `min-width: 0`, il élargirait la
57
+ colonne entière au lieu de défiler dans sa propre boîte. */
58
+ .sia-job-progress__logs { min-width: 0; }
@@ -1,8 +1,23 @@
1
1
  import { useMemo, useState, type TextareaHTMLAttributes } from "react";
2
- import { Textarea } from "../Textarea";
2
+ import { Textarea, type TextareaProps } from "../Textarea";
3
3
  import { cn } from "@sia-ui/utils";
4
+ import { useSiaLocale } from "@sia-ui/headless";
4
5
  import "./styles.css";
5
- export interface JsonEditorProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "defaultValue" | "onChange"> { value?: string; defaultValue?: string; onValueChange?: (value: string, parsed: unknown | undefined) => void; formatOnBlur?: boolean; }
6
+ export interface JsonEditorProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "defaultValue" | "onChange"> {
7
+ value?: string;
8
+ defaultValue?: string;
9
+ onValueChange?: (value: string, parsed: unknown | undefined) => void;
10
+ formatOnBlur?: boolean;
11
+ /**
12
+ * La zone de saisie elle-même : `resize`, classe, correcteur.
13
+ *
14
+ * La valeur, la saisie et `invalid` suivent l'analyse du JSON ; `onBlur`
15
+ * se passe au composant, qui l'enchaîne après le formatage.
16
+ */
17
+ textareaProps?: Partial<
18
+ Omit<TextareaProps, "value" | "defaultValue" | "onChange" | "invalid" | "onBlur">
19
+ >;
20
+ }
6
21
  /**
7
22
  * Une zone de saisie qui connaît la forme de ce qu'on y écrit.
8
23
  *
@@ -10,4 +25,4 @@ export interface JsonEditorProps extends Omit<TextareaHTMLAttributes<HTMLTextAre
10
25
  * analysé à côté du texte : l'appelant reçoit donc les deux, et n'a pas à
11
26
  * refaire un `JSON.parse` dans un `try`.
12
27
  */
13
- export function JsonEditor({ value, defaultValue = "{}", onValueChange, formatOnBlur = true, className, ...props }: JsonEditorProps) { const [internal, setInternal] = useState(defaultValue); const current = value ?? internal; const result = useMemo(() => { try { return { valid: true, parsed: JSON.parse(current) as unknown }; } catch { return { valid: false, parsed: undefined }; } }, [current]); const update = (next: string) => { let parsed: unknown | undefined; try { parsed = JSON.parse(next) as unknown; } catch { parsed = undefined; } if (value === undefined) setInternal(next); onValueChange?.(next, parsed); }; return <div className={cn("sia-code-editor", className)}><Textarea {...props} value={current} invalid={!result.valid} resize="vertical" spellCheck={false} onChange={(event) => update(event.target.value)} onBlur={(event) => { if (formatOnBlur && result.valid) update(JSON.stringify(result.parsed, null, 2)); props.onBlur?.(event); }} /><small className={result.valid ? "is-valid" : "is-invalid"}>{result.valid ? "JSON valide" : "JSON invalide"}</small></div>; }
28
+ export function JsonEditor({ value, defaultValue = "{}", onValueChange, formatOnBlur = true, className, textareaProps, ...props }: JsonEditorProps) { const messages = useSiaLocale().jsonEditor; const [internal, setInternal] = useState(defaultValue); const current = value ?? internal; const result = useMemo(() => { try { return { valid: true, parsed: JSON.parse(current) as unknown }; } catch { return { valid: false, parsed: undefined }; } }, [current]); const update = (next: string) => { let parsed: unknown | undefined; try { parsed = JSON.parse(next) as unknown; } catch { parsed = undefined; } if (value === undefined) setInternal(next); onValueChange?.(next, parsed); }; return <div className={cn("sia-code-editor", className)}><Textarea {...props} resize="vertical" spellCheck={false} {...textareaProps} value={current} invalid={!result.valid} onChange={(event) => update(event.target.value)} onBlur={(event) => { if (formatOnBlur && result.valid) update(JSON.stringify(result.parsed, null, 2)); props.onBlur?.(event); }} /><small className={result.valid ? "is-valid" : "is-invalid"}>{result.valid ? messages.valid : messages.invalid}</small></div>; }
@@ -0,0 +1,282 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import { cn } from "@sia-ui/utils";
3
+ import { formatMessage, plural, useSiaLocale, type JsonViewMessages } from "@sia-ui/headless";
4
+ import { useCopyToClipboard } from "@sia-ui/react";
5
+ import { Alert, type AlertProps } from "../Alert";
6
+ import { Button, type ButtonProps } from "../Button";
7
+ import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "../Icons";
8
+ import { ScrollArea, type ScrollAreaProps } from "../ScrollArea";
9
+ import "./styles.css";
10
+
11
+ export interface JsonViewProps {
12
+ /** Une valeur déjà analysée : objet, tableau, scalaire. */
13
+ value?: unknown;
14
+ /**
15
+ * Un texte JSON, analysé ici. S'il est invalide, il s'affiche tel quel
16
+ * sous une alerte discrète : une réponse serveur tronquée reste lisible.
17
+ * Prioritaire sur `value`.
18
+ */
19
+ source?: string;
20
+ /** Les niveaux ouverts au premier affichage. `2` par défaut. */
21
+ defaultExpandDepth?: number;
22
+ /** Faux : un simple bloc mis en forme, tout ouvert, sans bascule. */
23
+ collapsible?: boolean;
24
+ /** Au-delà, la zone défile. */
25
+ maxHeight?: number | string;
26
+ /** Vrai par défaut : un bouton copie le JSON mis en forme. */
27
+ copyable?: boolean;
28
+ copyLabel?: string;
29
+ copiedLabel?: string;
30
+ /** Le titre de l'alerte quand `source` ne s'analyse pas. */
31
+ invalidLabel?: ReactNode;
32
+ className?: string;
33
+
34
+ /** Le bouton de copie. Son action et son libellé suivent la copie. */
35
+ copyButtonProps?: Partial<Omit<ButtonProps, "onClick" | "children">>;
36
+ /** L'alerte d'un texte invalide. Son contenu est l'erreur d'analyse. */
37
+ alertProps?: Partial<Omit<AlertProps, "children">>;
38
+ /** La zone défilante. Sa hauteur maximale passe par `maxHeight`. */
39
+ scrollAreaProps?: Partial<Omit<ScrollAreaProps, "children" | "maxHeight">>;
40
+ }
41
+
42
+ type Analyse = { ok: true; data: unknown } | { ok: false; error: string };
43
+
44
+ interface Contexte {
45
+ depth: number;
46
+ collapsible: boolean;
47
+ /** Les textes, lus une fois par le composant et descendus aux nœuds. */
48
+ m: JsonViewMessages;
49
+ language: string;
50
+ }
51
+
52
+ function estComposite(value: unknown): value is Record<string, unknown> | unknown[] {
53
+ return typeof value === "object" && value !== null && !(value instanceof Date);
54
+ }
55
+
56
+ function scalaire(value: unknown) {
57
+ if (typeof value === "string") {
58
+ return <span className="sia-json-view__string">{JSON.stringify(value)}</span>;
59
+ }
60
+ if (typeof value === "number" || typeof value === "bigint") {
61
+ return <span className="sia-json-view__number">{String(value)}</span>;
62
+ }
63
+ if (typeof value === "boolean") {
64
+ return <span className="sia-json-view__boolean">{String(value)}</span>;
65
+ }
66
+ if (value instanceof Date) {
67
+ return <span className="sia-json-view__string">{JSON.stringify(value)}</span>;
68
+ }
69
+ // `null`, et ce que JSON ne sait pas écrire — `undefined`, une fonction :
70
+ // affichés quand même, en retrait, plutôt que de disparaître en silence.
71
+ return <span className="sia-json-view__null">{value === null ? "null" : String(value)}</span>;
72
+ }
73
+
74
+ interface NoeudProps {
75
+ /** Une clé d'objet, un indice de tableau, ou rien pour la racine. */
76
+ name?: string | number;
77
+ value: unknown;
78
+ level: number;
79
+ last: boolean;
80
+ ctx: Contexte;
81
+ }
82
+
83
+ /**
84
+ * Un nœud de l'arbre. Chacun tient son propre état d'ouverture : replier
85
+ * une branche ne redessine qu'elle, et un nœud replié ne rend pas ses
86
+ * enfants du tout.
87
+ */
88
+ function Noeud({ name, value, level, last, ctx }: NoeudProps) {
89
+ const [ouvert, setOuvert] = useState(!ctx.collapsible || level < ctx.depth);
90
+ const virgule = last ? null : <span className="sia-json-view__punct">,</span>;
91
+
92
+ const cle =
93
+ name === undefined ? null : (
94
+ <>
95
+ <span className={typeof name === "number" ? "sia-json-view__index" : "sia-json-view__key"}>
96
+ {typeof name === "number" ? name : JSON.stringify(name)}
97
+ </span>
98
+ <span className="sia-json-view__punct">: </span>
99
+ </>
100
+ );
101
+
102
+ if (!estComposite(value)) {
103
+ return (
104
+ <li className="sia-json-view__node">
105
+ <div className="sia-json-view__row">
106
+ {cle}
107
+ {scalaire(value)}
108
+ {virgule}
109
+ </div>
110
+ </li>
111
+ );
112
+ }
113
+
114
+ const tableau = Array.isArray(value);
115
+ const enfants: Array<[string | number, unknown]> = tableau
116
+ ? value.map((v, i) => [i, v])
117
+ : Object.entries(value);
118
+ const [debut, fin] = tableau ? ["[", "]"] : ["{", "}"];
119
+
120
+ if (enfants.length === 0) {
121
+ return (
122
+ <li className="sia-json-view__node">
123
+ <div className="sia-json-view__row">
124
+ {cle}
125
+ <span className="sia-json-view__punct">{debut + fin}</span>
126
+ {virgule}
127
+ </div>
128
+ </li>
129
+ );
130
+ }
131
+
132
+ const n = enfants.length;
133
+ const resume = tableau ? String(n) : plural(ctx.m, "keys", n, ctx.language);
134
+ const nom = name === undefined ? ctx.m.root : String(name);
135
+
136
+ return (
137
+ <li className="sia-json-view__node">
138
+ <div className="sia-json-view__row">
139
+ {/*
140
+ Un bouton natif et non `IconButton` : il y en a un par nœud, sur
141
+ des lignes d'une hauteur de texte, là où `IconButton` impose une
142
+ cible de bouton de barre d'outils.
143
+ */}
144
+ {ctx.collapsible && (
145
+ <button
146
+ type="button"
147
+ className="sia-json-view__toggle"
148
+ aria-expanded={ouvert}
149
+ aria-label={formatMessage(ouvert ? ctx.m.collapse : ctx.m.expand, { name: nom })}
150
+ onClick={() => setOuvert(!ouvert)}
151
+ >
152
+ {ouvert ? <ChevronDownIcon /> : <ChevronRightIcon />}
153
+ </button>
154
+ )}
155
+ {cle}
156
+ {ouvert ? (
157
+ <span className="sia-json-view__punct">{debut}</span>
158
+ ) : (
159
+ <>
160
+ <span className="sia-json-view__punct">{`${debut}…${fin}`}</span>
161
+ <span className="sia-json-view__summary">{resume}</span>
162
+ {virgule}
163
+ </>
164
+ )}
165
+ </div>
166
+ {ouvert && (
167
+ <>
168
+ <ul className="sia-json-view__children">
169
+ {enfants.map(([k, v], i) => (
170
+ <Noeud key={k} name={k} value={v} level={level + 1} last={i === n - 1} ctx={ctx} />
171
+ ))}
172
+ </ul>
173
+ <div className="sia-json-view__row">
174
+ <span className="sia-json-view__punct">{fin}</span>
175
+ {virgule}
176
+ </div>
177
+ </>
178
+ )}
179
+ </li>
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Un JSON en lecture seule, que l'on déplie.
185
+ *
186
+ * Pour une réponse d'API, une charge utile, une configuration : les types se
187
+ * distinguent à la couleur, les branches profondes restent repliées, et un
188
+ * nœud replié dit ce qu'il contient — `{…} 3 clés`. À la différence de
189
+ * `JsonEditor`, rien ne s'y saisit.
190
+ */
191
+ export function JsonView({
192
+ value,
193
+ source,
194
+ defaultExpandDepth = 2,
195
+ collapsible = true,
196
+ maxHeight,
197
+ copyable = true,
198
+ copyLabel: copyLabelProp,
199
+ copiedLabel: copiedLabelProp,
200
+ invalidLabel: invalidLabelProp,
201
+ className,
202
+ copyButtonProps,
203
+ alertProps,
204
+ scrollAreaProps,
205
+ }: JsonViewProps) {
206
+ const locale = useSiaLocale();
207
+ const m = locale.jsonView;
208
+ const copyLabel = copyLabelProp ?? locale.copy;
209
+ const copiedLabel = copiedLabelProp ?? locale.copied;
210
+ const invalidLabel = invalidLabelProp ?? m.invalid;
211
+ const { copy, copied } = useCopyToClipboard();
212
+
213
+ const analyse = useMemo<Analyse>(() => {
214
+ if (source === undefined) return { ok: true, data: value };
215
+ try {
216
+ return { ok: true, data: JSON.parse(source) as unknown };
217
+ } catch (erreur) {
218
+ return { ok: false, error: erreur instanceof Error ? erreur.message : String(erreur) };
219
+ }
220
+ }, [source, value]);
221
+
222
+ const ctx = useMemo<Contexte>(
223
+ () => ({ depth: defaultExpandDepth, collapsible, m, language: locale.language }),
224
+ [defaultExpandDepth, collapsible, m, locale.language],
225
+ );
226
+
227
+ // Au clic seulement : mettre en forme un gros document à chaque rendu
228
+ // coûterait pour une copie que personne ne fera peut-être.
229
+ const copier = () => {
230
+ const texte = analyse.ok
231
+ ? (JSON.stringify(analyse.data, null, 2) ?? String(analyse.data))
232
+ : (source ?? "");
233
+ void copy(texte);
234
+ };
235
+
236
+ return (
237
+ <div
238
+ className={cn(
239
+ "sia-json-view",
240
+ !collapsible && "sia-json-view--static",
241
+ copyable && "sia-json-view--copyable",
242
+ className,
243
+ )}
244
+ >
245
+ {!analyse.ok && (
246
+ <Alert tone="warning" title={invalidLabel} {...alertProps}>
247
+ {/* Le message du moteur (« Unexpected end of JSON input ») est en
248
+ anglais et varie d'un navigateur à l'autre : une phrase stable,
249
+ le détail technique en infobulle pour qui le cherche. */}
250
+ <span title={analyse.error}>{m.invalidDescription}</span>
251
+ </Alert>
252
+ )}
253
+ <div className="sia-json-view__frame">
254
+ {copyable && (
255
+ <Button
256
+ variant="ghost"
257
+ size="sm"
258
+ {...(copied ? { leftIcon: <CheckIcon /> } : {})}
259
+ {...copyButtonProps}
260
+ className={cn("sia-json-view__copy", copyButtonProps?.className)}
261
+ onClick={copier}
262
+ >
263
+ {copied ? copiedLabel : copyLabel}
264
+ </Button>
265
+ )}
266
+ <ScrollArea
267
+ {...scrollAreaProps}
268
+ {...(maxHeight !== undefined ? { maxHeight } : {})}
269
+ className={cn("sia-json-view__scroll", scrollAreaProps?.className)}
270
+ >
271
+ {analyse.ok ? (
272
+ <ul className="sia-json-view__tree">
273
+ <Noeud value={analyse.data} level={0} last ctx={ctx} />
274
+ </ul>
275
+ ) : (
276
+ <pre className="sia-json-view__raw">{source}</pre>
277
+ )}
278
+ </ScrollArea>
279
+ </div>
280
+ </div>
281
+ );
282
+ }