@tai42/studio-sdk 0.12.0 → 0.13.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 (75) hide show
  1. package/dist/components/code-block.d.ts.map +1 -1
  2. package/dist/components/code-block.js +26 -13
  3. package/dist/components/code-block.js.map +1 -1
  4. package/dist/components/components.css +20 -0
  5. package/dist/components/copy-field.d.ts.map +1 -1
  6. package/dist/components/copy-field.js +8 -73
  7. package/dist/components/copy-field.js.map +1 -1
  8. package/dist/components/date-range-picker.d.ts +73 -0
  9. package/dist/components/date-range-picker.d.ts.map +1 -0
  10. package/dist/components/date-range-picker.js +146 -0
  11. package/dist/components/date-range-picker.js.map +1 -0
  12. package/dist/components/dialog.d.ts +19 -1
  13. package/dist/components/dialog.d.ts.map +1 -1
  14. package/dist/components/dialog.js +16 -13
  15. package/dist/components/dialog.js.map +1 -1
  16. package/dist/components/dirty-guard.d.ts +28 -0
  17. package/dist/components/dirty-guard.d.ts.map +1 -0
  18. package/dist/components/dirty-guard.js +93 -0
  19. package/dist/components/dirty-guard.js.map +1 -0
  20. package/dist/components/extension-picker.d.ts.map +1 -1
  21. package/dist/components/extension-picker.js +7 -0
  22. package/dist/components/extension-picker.js.map +1 -1
  23. package/dist/components/form-dialog.d.ts +45 -0
  24. package/dist/components/form-dialog.d.ts.map +1 -0
  25. package/dist/components/form-dialog.js +71 -0
  26. package/dist/components/form-dialog.js.map +1 -0
  27. package/dist/components/json-tree.d.ts +7 -1
  28. package/dist/components/json-tree.d.ts.map +1 -1
  29. package/dist/components/json-tree.js +300 -13
  30. package/dist/components/json-tree.js.map +1 -1
  31. package/dist/components/markdown.d.ts +60 -0
  32. package/dist/components/markdown.d.ts.map +1 -0
  33. package/dist/components/markdown.js +435 -0
  34. package/dist/components/markdown.js.map +1 -0
  35. package/dist/components/tabs.d.ts +3 -1
  36. package/dist/components/tabs.d.ts.map +1 -1
  37. package/dist/components/tabs.js +2 -2
  38. package/dist/components/tabs.js.map +1 -1
  39. package/dist/hooks/useApi.d.ts +56 -28
  40. package/dist/hooks/useApi.d.ts.map +1 -1
  41. package/dist/hooks/useClipboardCopy.d.ts +26 -0
  42. package/dist/hooks/useClipboardCopy.d.ts.map +1 -0
  43. package/dist/hooks/useClipboardCopy.js +72 -0
  44. package/dist/hooks/useClipboardCopy.js.map +1 -0
  45. package/dist/index.d.ts +9 -2
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +5 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/navigation/context.d.ts +13 -1
  50. package/dist/navigation/context.d.ts.map +1 -1
  51. package/dist/navigation/context.js +205 -7
  52. package/dist/navigation/context.js.map +1 -1
  53. package/dist/navigation/index.d.ts +2 -2
  54. package/dist/navigation/index.d.ts.map +1 -1
  55. package/dist/navigation/index.js +1 -1
  56. package/dist/navigation/index.js.map +1 -1
  57. package/dist/navigation/types.d.ts +11 -0
  58. package/dist/navigation/types.d.ts.map +1 -1
  59. package/package.json +2 -2
  60. package/src/components/code-block.tsx +72 -9
  61. package/src/components/components.css +20 -0
  62. package/src/components/copy-field.tsx +25 -81
  63. package/src/components/date-range-picker.tsx +256 -0
  64. package/src/components/dialog.tsx +59 -18
  65. package/src/components/dirty-guard.tsx +153 -0
  66. package/src/components/extension-picker.tsx +12 -0
  67. package/src/components/form-dialog.tsx +133 -0
  68. package/src/components/json-tree.tsx +477 -42
  69. package/src/components/markdown.tsx +552 -0
  70. package/src/components/tabs.tsx +10 -1
  71. package/src/hooks/useClipboardCopy.ts +105 -0
  72. package/src/index.ts +18 -0
  73. package/src/navigation/context.tsx +245 -7
  74. package/src/navigation/index.ts +2 -0
  75. package/src/navigation/types.ts +12 -0
@@ -1,24 +1,45 @@
1
1
  /**
2
2
  * `JsonTree` — a collapsible viewer for arbitrary JSON on the design system's
3
- * terminal ground (`tai-code-block`, which also supplies the mono face, the code
4
- * size and the line rhythm every row inherits). Objects and arrays are native
5
- * `<details>`/`<summary>` disclosures (keyboard-accessible, no custom ARIA
6
- * needed); primitives render inline, tinted by the `tai-syntax-*` class for their
7
- * type so a value's kind is readable at a glance. All values render as React TEXT
8
- * children, so a payload containing markup (e.g. `<script>`) is escaped by React
9
- * and never interpreted — this component is never an HTML sink.
3
+ * terminal ground (`tai-code-block`). Objects and arrays are native
4
+ * `<details>`/`<summary>` disclosures; primitives render inline, tinted by their
5
+ * `tai-syntax-*` type class. All values render as React TEXT children, so markup in
6
+ * a payload is escaped — never an HTML sink.
10
7
  *
11
- * The pane IS the scrolling box, so it carries the region attributes itself
12
- * rather than sitting inside a `ScrollRegion` wrapper that would add a second
13
- * scroller: a deeply indented value makes it a named keyboard target, a shallow
14
- * one leaves it an ordinary block.
8
+ * It mounts against guaranteed-large payloads (a trace span's whole input/output),
9
+ * so it is BOUNDED by construction:
10
+ *
11
+ * - Nodes deeper than {@link AUTO_EXPAND_DEPTH} start COLLAPSED, and a collapsed node
12
+ * renders no children. `defaultExpanded` overrides per call site.
13
+ * - Expand-all opens breadth-first only until {@link AUTO_EXPAND_NODE_BUDGET} nodes
14
+ * render and never past {@link MAX_AUTO_DEPTH}; a node beyond either opens on click.
15
+ * - A container over {@link PAGE_SIZE} children renders one page plus a "show more".
16
+ * - Copy-whole and copy-node write JSON to the clipboard; a refused write shows an alert.
17
+ *
18
+ * The scrolling pane IS the scrolling box, carrying the region attributes itself
19
+ * rather than nesting a `ScrollRegion` that would add a second scroller.
15
20
  */
16
- import type { CSSProperties, ReactElement } from 'react';
21
+ import {
22
+ createContext,
23
+ useCallback,
24
+ useContext,
25
+ useEffect,
26
+ useMemo,
27
+ useRef,
28
+ useState,
29
+ } from 'react';
30
+ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement } from 'react';
17
31
 
32
+ import { CheckIcon, CopyIcon, XCircleIcon } from './icons';
18
33
  import { useOverflowRegion } from './scroll-region';
34
+ import { COPIED_LABEL, COPIED_RESET_MS, useClipboardCopy } from '../hooks/useClipboardCopy';
19
35
 
20
36
  export interface JsonTreeProps {
21
37
  readonly data: unknown;
38
+ /**
39
+ * Opts the whole tree out of the depth-guarded default: `true` expands every
40
+ * node (up to the depth cap), `false` collapses to the root. Omitted, the tree
41
+ * opens through {@link AUTO_EXPAND_DEPTH} and collapses everything below.
42
+ */
22
43
  readonly defaultExpanded?: boolean;
23
44
  /** The region's accessible name, applied only while the pane actually scrolls. */
24
45
  readonly label?: string;
@@ -27,10 +48,51 @@ export interface JsonTreeProps {
27
48
  /** The region's name when the caller supplies none. */
28
49
  const DEFAULT_LABEL = 'JSON';
29
50
 
51
+ /** The deepest level opened by the guarded default; deeper nodes start collapsed. */
52
+ const AUTO_EXPAND_DEPTH = 1;
53
+
54
+ /**
55
+ * The deepest level EXPAND-ALL reaches. Past it a node opens only on an explicit
56
+ * click, so the one action that opens the whole tree still cannot force an
57
+ * unbounded-depth payload into the DOM in a single stroke.
58
+ */
59
+ const MAX_AUTO_DEPTH = 6;
60
+
61
+ /** Children rendered per page; the rest wait behind a "show more" control. */
62
+ const PAGE_SIZE = 100;
63
+
64
+ /**
65
+ * The most rendered nodes EXPAND-ALL may auto-open in one stroke. The depth cap and
66
+ * the per-page limit each bound ONE dimension, but a payload that is wide AND deep
67
+ * multiplies them — auto-opening every node up to the cap would lay out on the order
68
+ * of PAGE_SIZE^depth nodes and freeze the tab. Expand-all instead opens containers
69
+ * breadth-first only until this budget is spent; a node past it stays collapsed and
70
+ * opens on an explicit click, so a huge payload opens a bounded, usable subset.
71
+ */
72
+ const AUTO_EXPAND_NODE_BUDGET = 1500;
73
+
74
+ /** A copy control's resting face; whichever is exposed is also its accessible name. */
75
+ const COPY_LABEL = 'Copy';
76
+
77
+ /** Shown when the browser offers no clipboard at all (any non-secure context). */
78
+ const NO_CLIPBOARD =
79
+ 'This browser will not write to the clipboard here. Select the value and copy it by hand.';
80
+
81
+ /** Shown when the write, or the value's serialization, is offered and refused. */
82
+ function copyFailed(reason: unknown): string {
83
+ const detail = reason instanceof Error ? reason.message : String(reason);
84
+ return `Copy failed: ${detail}.`;
85
+ }
86
+
30
87
  function isRecord(value: unknown): value is Record<string, unknown> {
31
88
  return typeof value === 'object' && value !== null && !Array.isArray(value);
32
89
  }
33
90
 
91
+ /** Whether a value is a disclosure (object or array) rather than a leaf. */
92
+ function isContainer(value: unknown): boolean {
93
+ return Array.isArray(value) || isRecord(value);
94
+ }
95
+
34
96
  function primitiveText(value: unknown): string {
35
97
  if (value === null) return 'null';
36
98
  switch (typeof value) {
@@ -51,6 +113,123 @@ function primitiveText(value: unknown): string {
51
113
  }
52
114
  }
53
115
 
116
+ /**
117
+ * `JSON.stringify` is TYPED to always return `string`, but a value it cannot
118
+ * represent (a function, a bare `undefined`) yields `undefined` at runtime. This
119
+ * boundary admits the absence the standard-library overload denies, so the guard
120
+ * below is a real check rather than a cast around one.
121
+ */
122
+ function stringifyJson(value: unknown): string | undefined {
123
+ return JSON.stringify(value, null, 2);
124
+ }
125
+
126
+ /**
127
+ * The value's pretty JSON for the clipboard. A value JSON cannot represent (a
128
+ * function, a bare `undefined`) has no JSON, so its readable form is copied
129
+ * rather than an empty string. A value JSON REFUSES (a `bigint`) throws here, and
130
+ * the caller renders that as the same visible alert a blocked write is.
131
+ */
132
+ function serializeForCopy(value: unknown): string {
133
+ return stringifyJson(value) ?? String(value);
134
+ }
135
+
136
+ /**
137
+ * A child's identity for the open-state map. Each segment is percent-encoded, so a
138
+ * key that itself contains the separator cannot collide with a different path.
139
+ */
140
+ function childPath(parent: string, name: string): string {
141
+ const segment = encodeURIComponent(name);
142
+ return parent === '' ? segment : `${parent}/${segment}`;
143
+ }
144
+
145
+ /** The default open state a call site's `defaultExpanded` selects. */
146
+ type Baseline = 'depth' | 'expanded' | 'collapsed';
147
+
148
+ function initialBaseline(defaultExpanded: boolean | undefined): Baseline {
149
+ if (defaultExpanded === true) return 'expanded';
150
+ if (defaultExpanded === false) return 'collapsed';
151
+ return 'depth';
152
+ }
153
+
154
+ /**
155
+ * The set of paths EXPAND-ALL auto-opens: a breadth-first sweep that opens each
156
+ * container in turn — spending its visible child count against a shared node budget
157
+ * and enqueuing that page's own container children — skipping any node whose page
158
+ * would overspend {@link AUTO_EXPAND_NODE_BUDGET} or that sits past {@link MAX_AUTO_DEPTH}.
159
+ * Only a bounded window of a huge payload is materialized, so this stays cheap even
160
+ * when the data is enormous; the paths it omits stay collapsed and open on a click.
161
+ */
162
+ function expandedOpenPaths(root: unknown): ReadonlySet<string> {
163
+ const open = new Set<string>();
164
+ if (!isContainer(root)) return open;
165
+
166
+ let budget = AUTO_EXPAND_NODE_BUDGET;
167
+ const queue: { readonly value: unknown; readonly path: string; readonly depth: number }[] = [
168
+ { value: root, path: '', depth: 0 },
169
+ ];
170
+
171
+ while (queue.length > 0) {
172
+ const node = queue.shift();
173
+ if (node === undefined) break;
174
+ if (node.depth >= MAX_AUTO_DEPTH) continue;
175
+
176
+ const shown = Math.min(PAGE_SIZE, containerCount(node.value));
177
+ // Skip when the budget can't seat a whole page (a later, smaller node may still fit).
178
+ if (shown > budget) continue;
179
+ budget -= shown;
180
+ open.add(node.path);
181
+
182
+ for (const [name, childValue] of sliceEntries(node.value, shown)) {
183
+ if (isContainer(childValue)) {
184
+ queue.push({ value: childValue, path: childPath(node.path, name), depth: node.depth + 1 });
185
+ }
186
+ }
187
+ }
188
+
189
+ return open;
190
+ }
191
+
192
+ /**
193
+ * Whether a node is open, from the baseline and the reader's own toggles. An
194
+ * explicit toggle always wins; otherwise the baseline decides — the depth-guarded
195
+ * default opens through {@link AUTO_EXPAND_DEPTH}, expand-all opens the budgeted
196
+ * breadth-first set, collapse-all opens nothing.
197
+ */
198
+ function computeOpen(
199
+ baseline: Baseline,
200
+ overrides: ReadonlyMap<string, boolean>,
201
+ expandedPaths: ReadonlySet<string> | null,
202
+ path: string,
203
+ depth: number,
204
+ ): boolean {
205
+ const override = overrides.get(path);
206
+ if (override !== undefined) return override;
207
+ if (baseline === 'expanded') return expandedPaths?.has(path) ?? false;
208
+ if (baseline === 'collapsed') return false;
209
+ return depth <= AUTO_EXPAND_DEPTH;
210
+ }
211
+
212
+ interface JsonTreeContextValue {
213
+ /** Whether the node at `path`/`depth` is open right now. */
214
+ readonly isOpen: (path: string, depth: number) => boolean;
215
+ /** Records the reader's explicit toggle of one node. */
216
+ readonly setOpen: (path: string, open: boolean) => void;
217
+ /** Copies a value's JSON, returning whether the write succeeded. */
218
+ readonly copy: (value: unknown) => Promise<boolean>;
219
+ readonly pageSize: number;
220
+ }
221
+
222
+ const JsonTreeContext = createContext<JsonTreeContextValue | null>(null);
223
+
224
+ /** The shared tree state; a subcomponent outside a `JsonTree` is a wiring error. */
225
+ function useJsonTreeContext(): JsonTreeContextValue {
226
+ const context = useContext(JsonTreeContext);
227
+ if (context === null) {
228
+ throw new Error('JsonTree subcomponents must be rendered inside a JsonTree.');
229
+ }
230
+ return context;
231
+ }
232
+
54
233
  /**
55
234
  * One nesting level's indent: the child block insets and draws its own guide
56
235
  * rail. The depth is expressed by the DOM nesting, so each level repeats this
@@ -63,13 +242,27 @@ const childrenStyle: CSSProperties = {
63
242
  };
64
243
 
65
244
  /** A `<summary>` is a click target; browsers do not style it as one by default. */
66
- const summaryStyle: CSSProperties = { cursor: 'pointer' };
245
+ const summaryStyle: CSSProperties = {
246
+ display: 'flex',
247
+ alignItems: 'center',
248
+ gap: 'var(--tai-space-2)',
249
+ cursor: 'pointer',
250
+ };
67
251
 
68
- interface NodeProps {
69
- readonly name?: string;
70
- readonly value: unknown;
71
- readonly defaultExpanded: boolean;
72
- }
252
+ /** The copy control sits at the far end of a disclosure's row. */
253
+ const nodeCopyStyle: CSSProperties = { marginLeft: 'auto' };
254
+
255
+ /**
256
+ * The per-node copy button's geometry: borrows `tai-icon-btn` for the focus ring,
257
+ * muted ink and hover, then shrinks to the code rhythm so the row stays dense.
258
+ */
259
+ const nodeCopyButtonStyle: CSSProperties = {
260
+ width: 'auto',
261
+ height: 'auto',
262
+ padding: 'var(--tai-space-1)',
263
+ };
264
+
265
+ const showMoreStyle: CSSProperties = { marginTop: 'var(--tai-space-1)' };
73
266
 
74
267
  /** The syntax class for a primitive's type; anything outside JSON reads as muted. */
75
268
  function primitiveClass(value: unknown): string {
@@ -80,30 +273,191 @@ function primitiveClass(value: unknown): string {
80
273
  return 'tai-muted';
81
274
  }
82
275
 
83
- function JsonNode({ name, value, defaultExpanded }: NodeProps): ReactElement {
84
- if (Array.isArray(value) || isRecord(value)) {
85
- const entries: [string, unknown][] = Array.isArray(value)
86
- ? value.map((item, index) => [String(index), item])
87
- : Object.entries(value);
88
- const summary = Array.isArray(value)
89
- ? `Array(${String(value.length)})`
90
- : `Object(${String(entries.length)})`;
276
+ interface CopyButtonProps {
277
+ readonly value: unknown;
278
+ /**
279
+ * The button's resting accessible name. When it flips to its confirmed state the
280
+ * name flips with it, so a control reading "Copied" is never still named "Copy"
281
+ * (WCAG 2.5.3). A `text` button spells the name in its own words; an `icon` button
282
+ * takes it from here.
283
+ */
284
+ readonly label: string;
285
+ readonly variant: 'text' | 'icon';
286
+ }
287
+
288
+ /**
289
+ * A copy control that writes its value to the clipboard and shows a transient
290
+ * confirmed state. The write itself lives on the tree context, so a refusal is
291
+ * reported ONCE — as the tree's shared alert — rather than inline at each of the
292
+ * many buttons a large payload carries.
293
+ */
294
+ function CopyButton({ value, label, variant }: CopyButtonProps): ReactElement {
295
+ const { copy } = useJsonTreeContext();
296
+ const [copied, setCopied] = useState(false);
297
+ const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
298
+ // The write is async, so a copy can be in flight when the button unmounts (a dialog
299
+ // holding the tree closes on the same click); checked before the resolution sets state.
300
+ const mounted = useRef(true);
301
+
302
+ useEffect(() => {
303
+ mounted.current = true;
304
+ return () => {
305
+ mounted.current = false;
306
+ if (resetTimer.current !== null) clearTimeout(resetTimer.current);
307
+ };
308
+ }, []);
309
+
310
+ const handleClick = (event: ReactMouseEvent<HTMLButtonElement>): void => {
311
+ // Inside a `<summary>` a click both toggles the disclosure natively and reaches
312
+ // the summary's own toggle handler; neither should fire for a copy.
313
+ event.preventDefault();
314
+ event.stopPropagation();
315
+ void copy(value).then((ok) => {
316
+ if (!ok || !mounted.current) return;
317
+ setCopied(true);
318
+ if (resetTimer.current !== null) clearTimeout(resetTimer.current);
319
+ resetTimer.current = setTimeout(() => {
320
+ if (mounted.current) setCopied(false);
321
+ }, COPIED_RESET_MS);
322
+ });
323
+ };
324
+
325
+ if (variant === 'text') {
326
+ return (
327
+ <button type="button" className="tai-btn tai-btn-ghost" onClick={handleClick}>
328
+ {copied ? <CheckIcon /> : <CopyIcon />}
329
+ {copied ? COPIED_LABEL : label}
330
+ </button>
331
+ );
332
+ }
333
+
334
+ return (
335
+ <button
336
+ type="button"
337
+ className="tai-icon-btn"
338
+ style={{ ...nodeCopyStyle, ...nodeCopyButtonStyle }}
339
+ aria-label={copied ? COPIED_LABEL : label}
340
+ onClick={handleClick}
341
+ >
342
+ {copied ? <CheckIcon /> : <CopyIcon />}
343
+ </button>
344
+ );
345
+ }
346
+
347
+ interface NodeProps {
348
+ readonly name?: string;
349
+ readonly value: unknown;
350
+ readonly depth: number;
351
+ readonly path: string;
352
+ }
353
+
354
+ /** A container's child count, without materializing its entries. */
355
+ function containerCount(value: unknown): number {
356
+ if (Array.isArray(value)) return value.length;
357
+ if (isRecord(value)) return Object.keys(value).length;
358
+ throw new Error('containerCount expects an array or a record.');
359
+ }
360
+
361
+ /**
362
+ * The first `end` entries of a container, arrays keyed by index and objects by
363
+ * key. Only the visible window is materialized, so a million-element array's
364
+ * children never all exist at once.
365
+ */
366
+ function sliceEntries(value: unknown, end: number): [string, unknown][] {
367
+ if (Array.isArray(value)) {
368
+ return value.slice(0, end).map((item, index) => [String(index), item]);
369
+ }
370
+ // Slice the keys before reading values, so only the visible window is materialized.
371
+ if (isRecord(value))
372
+ return Object.keys(value)
373
+ .slice(0, end)
374
+ .map((key) => [key, value[key]]);
375
+ throw new Error('sliceEntries expects an array or a record.');
376
+ }
377
+
378
+ /** One container's summary text: its kind and its child count. */
379
+ function containerSummary(value: unknown, count: number): string {
380
+ return Array.isArray(value) ? `Array(${String(count)})` : `Object(${String(count)})`;
381
+ }
382
+
383
+ /**
384
+ * The visible slice of a container's children, plus a "show more" control while
385
+ * any remain. Rendered ONLY when the node is open, so a collapsed container costs
386
+ * nothing and the page count follows how far the reader has opened it.
387
+ */
388
+ function NodeChildren({
389
+ value,
390
+ count,
391
+ depth,
392
+ path,
393
+ }: {
394
+ readonly value: unknown;
395
+ readonly count: number;
396
+ readonly depth: number;
397
+ readonly path: string;
398
+ }): ReactElement {
399
+ const { pageSize } = useJsonTreeContext();
400
+ const [visible, setVisible] = useState(pageSize);
401
+ const shownCount = Math.min(visible, count);
402
+ const shown = sliceEntries(value, shownCount);
403
+ const remaining = count - shownCount;
404
+
405
+ return (
406
+ <div style={childrenStyle}>
407
+ {shown.map(([childName, childValue]) => (
408
+ <JsonNode
409
+ key={childName}
410
+ name={childName}
411
+ value={childValue}
412
+ depth={depth + 1}
413
+ path={childPath(path, childName)}
414
+ />
415
+ ))}
416
+ {remaining > 0 ? (
417
+ <button
418
+ type="button"
419
+ className="tai-btn tai-btn-ghost"
420
+ style={showMoreStyle}
421
+ onClick={() => {
422
+ setVisible((current) => current + pageSize);
423
+ }}
424
+ >
425
+ Show {String(Math.min(remaining, pageSize))} more
426
+ </button>
427
+ ) : null}
428
+ </div>
429
+ );
430
+ }
431
+
432
+ function JsonNode({ name, value, depth, path }: NodeProps): ReactElement {
433
+ const { isOpen, setOpen } = useJsonTreeContext();
434
+
435
+ if (isContainer(value)) {
436
+ const count = containerCount(value);
437
+ const open = isOpen(path, depth);
438
+ const summary = containerSummary(value, count);
439
+ const handleToggle = (event: ReactMouseEvent<HTMLElement>): void => {
440
+ // The disclosure is state-driven: cancel the browser's own toggle so the two
441
+ // never diverge, then record the reader's intent.
442
+ event.preventDefault();
443
+ setOpen(path, !open);
444
+ };
445
+
91
446
  return (
92
- <details open={defaultExpanded}>
93
- <summary style={summaryStyle}>
447
+ <details open={open}>
448
+ <summary style={summaryStyle} onClick={handleToggle}>
94
449
  {name !== undefined ? <span className="tai-syntax-key">{name}: </span> : null}
95
450
  <span className="tai-muted">{summary}</span>
96
- </summary>
97
- <div style={childrenStyle}>
98
- {entries.map(([childName, childValue]) => (
99
- <JsonNode
100
- key={childName}
101
- name={childName}
102
- value={childValue}
103
- defaultExpanded={defaultExpanded}
451
+ {/* The root's copy-node control would duplicate the toolbar's copy-whole. */}
452
+ {depth > 0 ? (
453
+ <CopyButton
454
+ value={value}
455
+ variant="icon"
456
+ label={name !== undefined ? `Copy ${name}` : COPY_LABEL}
104
457
  />
105
- ))}
106
- </div>
458
+ ) : null}
459
+ </summary>
460
+ {open ? <NodeChildren value={value} count={count} depth={depth} path={path} /> : null}
107
461
  </details>
108
462
  );
109
463
  }
@@ -116,14 +470,95 @@ function JsonNode({ name, value, defaultExpanded }: NodeProps): ReactElement {
116
470
  );
117
471
  }
118
472
 
119
- export function JsonTree({ data, defaultExpanded = true, label }: JsonTreeProps) {
473
+ export function JsonTree({ data, defaultExpanded, label }: JsonTreeProps): ReactElement {
474
+ const [baseline, setBaseline] = useState<Baseline>(() => initialBaseline(defaultExpanded));
475
+ const [overrides, setOverrides] = useState<ReadonlyMap<string, boolean>>(() => new Map());
476
+
477
+ const {
478
+ error: copyError,
479
+ announcement,
480
+ copy: copyText,
481
+ } = useClipboardCopy({
482
+ noClipboard: NO_CLIPBOARD,
483
+ writeFailed: copyFailed,
484
+ });
485
+
120
486
  // No consumer ref: the measurement's own callback ref is the only thing that
121
487
  // needs the pane, and this component publishes no `ref` prop.
122
488
  const region = useOverflowRegion(undefined, label ?? DEFAULT_LABEL);
123
489
 
490
+ // Expand-all's open set is a function of the data alone, and it is consulted only
491
+ // while that baseline is active; deriving it lazily keeps a collapsed or
492
+ // depth-default tree from sweeping the payload at all.
493
+ const expandedPaths = useMemo(
494
+ () => (baseline === 'expanded' ? expandedOpenPaths(data) : null),
495
+ [baseline, data],
496
+ );
497
+
498
+ const isOpen = useCallback(
499
+ (path: string, depth: number): boolean =>
500
+ computeOpen(baseline, overrides, expandedPaths, path, depth),
501
+ [baseline, overrides, expandedPaths],
502
+ );
503
+
504
+ const setOpen = useCallback((path: string, open: boolean): void => {
505
+ setOverrides((previous) => {
506
+ const next = new Map(previous);
507
+ next.set(path, open);
508
+ return next;
509
+ });
510
+ }, []);
511
+
512
+ // The value's JSON is produced lazily inside the write, so a value JSON refuses
513
+ // (a `bigint`) surfaces as the same visible alert a blocked write does.
514
+ const copy = useCallback(
515
+ (value: unknown): Promise<boolean> => copyText(() => serializeForCopy(value)),
516
+ [copyText],
517
+ );
518
+
519
+ const context = useMemo<JsonTreeContextValue>(
520
+ () => ({ isOpen, setOpen, copy, pageSize: PAGE_SIZE }),
521
+ [isOpen, setOpen, copy],
522
+ );
523
+
524
+ const expandAll = (): void => {
525
+ setBaseline('expanded');
526
+ setOverrides(new Map());
527
+ };
528
+ const collapseAll = (): void => {
529
+ setBaseline('collapsed');
530
+ setOverrides(new Map());
531
+ };
532
+
533
+ const showToolbar = isContainer(data);
534
+
124
535
  return (
125
- <div className="tai-code-block" {...region}>
126
- <JsonNode value={data} defaultExpanded={defaultExpanded} />
127
- </div>
536
+ <JsonTreeContext.Provider value={context}>
537
+ <div className="tai-stack tai-stack-2">
538
+ {showToolbar ? (
539
+ <div className="tai-row">
540
+ <button type="button" className="tai-btn tai-btn-ghost" onClick={expandAll}>
541
+ Expand all
542
+ </button>
543
+ <button type="button" className="tai-btn tai-btn-ghost" onClick={collapseAll}>
544
+ Collapse all
545
+ </button>
546
+ <CopyButton value={data} variant="text" label={COPY_LABEL} />
547
+ <span aria-live="polite" className="tai-visually-hidden">
548
+ {announcement}
549
+ </span>
550
+ </div>
551
+ ) : null}
552
+ {copyError !== undefined ? (
553
+ <span role="alert" className="tai-field-error">
554
+ <XCircleIcon />
555
+ {copyError}
556
+ </span>
557
+ ) : null}
558
+ <div className="tai-code-block" {...region}>
559
+ <JsonNode value={data} depth={0} path="" />
560
+ </div>
561
+ </div>
562
+ </JsonTreeContext.Provider>
128
563
  );
129
564
  }