@script-it/json-views-react 0.1.0-beta.1 → 2026.10.25

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 (35) hide show
  1. package/README.md +24 -3
  2. package/dist/index.d.ts +1 -0
  3. package/dist/json/html-view/format-value.d.ts +5 -0
  4. package/dist/json/html-view/format-value.js +84 -0
  5. package/dist/json/html-view/native-control.d.ts +3 -8
  6. package/dist/json/html-view/native-control.js +3 -1
  7. package/dist/json/html-view/template.js +5 -0
  8. package/dist/json/html-view.js +50 -8
  9. package/dist/json/json-content.d.ts +3 -0
  10. package/dist/json/json-content.js +22 -15
  11. package/dist/json/json-view.d.ts +1 -1
  12. package/dist/json/json-view.js +34 -32
  13. package/dist/json/schema-value.js +11 -3
  14. package/dist/json/table-view.js +16 -11
  15. package/dist/json/view-model.d.ts +1 -1
  16. package/dist/json/view-model.js +3 -1
  17. package/dist/json/view-settings.js +42 -23
  18. package/dist/json/view-types.d.ts +3 -0
  19. package/dist/json/viewers/general/json-node-view.js +6 -2
  20. package/dist/lib/enter-key.d.ts +29 -0
  21. package/dist/lib/enter-key.js +27 -0
  22. package/dist/primitives/choice-control.js +2 -1
  23. package/dist/structured-data/atomic-value-editor.d.ts +4 -1
  24. package/dist/structured-data/atomic-value-editor.js +17 -9
  25. package/dist/structured-data/table-cell-options.d.ts +20 -0
  26. package/dist/structured-data/table-cell-options.js +11 -0
  27. package/dist/structured-data/tabular-data-cell.d.ts +19 -0
  28. package/dist/structured-data/tabular-data-cell.js +55 -0
  29. package/dist/styles.css +1 -1
  30. package/dist/tabular-data-view.js +23 -3
  31. package/dist/widget-registry.d.ts +5 -0
  32. package/dist/widget-registry.js +13 -7
  33. package/dist/widgets/date-picker-widget.js +11 -0
  34. package/dist/widgets/textarea-widget.js +4 -1
  35. package/package.json +4 -4
package/README.md CHANGED
@@ -155,6 +155,8 @@ Custom descriptor properties are preserved and passed to both validation and edi
155
155
 
156
156
  Editors receive `value`, `stringValue`, `descriptor`, `disabled`, `error`, and `onChange`/`onCommit`/`onCancel`. `onChange` updates the local draft; `onCommit(value)` submits a candidate, while `onCommit()` submits the current draft. Respect `disabled` while a save is pending. An optional display receives the canonical JSON value, `compact`, `onOpen`, and the host Markdown renderer. Object and array values use the same widget contract, including at the current/root location, with an Expand action for structural inspection.
157
157
 
158
+ A registration's `enterKey` sets what a plain Enter does in the editor's multiline controls: `'submit'` (default) saves and leaves Shift+Enter for a line break, while `'newline'` keeps Enter for line breaks and saves on Ctrl/Cmd+Enter, as the built-in `markdown`, `body`, and `html` editors do. Replacing only a type's editor keeps its `enterKey`. A widget that handles Enter itself, such as a code editor, calls `event.preventDefault()` on that keydown to keep it.
159
+
158
160
  Core `filterOperators`, `matchesFilter`, and `compare` supply custom filtering and ordering. Comparators must define a consistent order. Register implementations before compiling annotations; use registry methods to replace or unregister them. Registries notify mounted viewers of changes. Create a provider-scoped registry or clone when behavior must differ across instances. See the [complete extension contract](annotation-spec.md#extension-lifecycle) and the [working custom-object consumer](../examples/consumer/src/main.tsx).
159
161
 
160
162
  Registrations are additive. Replacing only a type's editor preserves its existing display widget:
@@ -167,6 +169,23 @@ const widgets = createDefaultWidgetRegistry().register('date', {
167
169
 
168
170
  For optional dates, `JSONContent` sets Clear to `null` by default. Pass `clearBehavior="remove"` when the host should remove the property instead.
169
171
 
172
+ ### Tall table cells
173
+
174
+ Table cells clip values taller than `9rem` (about six lines), so long text and wrapped choices keep rows scannable. Selecting a clipped cell floats its whole value over the rows below without changing any row height; editing a cell opens its editor in that same surface. A record's title cell keeps opening the record, which shows the whole value. Configure this with the optional `tableCells` prop on `JSONContent`, `CSVContent`, or `JsonViewer`:
175
+
176
+ ```tsx
177
+ <JSONContent
178
+ content={content}
179
+ tableCells={{
180
+ overflow: 'clamp', // default; 'grow' lets rows fit their tallest value
181
+ maxHeight: 160, // pixels or any CSS length; default '9rem'
182
+ reveal: 'select', // default; 'none' keeps clipped cells clipped
183
+ }}
184
+ />
185
+ ```
186
+
187
+ Hosts can also set the limit in CSS, per container or breakpoint, with `--json-views-table-cell-max-height`; a `maxHeight` prop takes precedence for its viewer.
188
+
170
189
  Built-in `select` and `multi-select` widgets are creatable. Descriptor `options` provide the initial suggestions, while new string values remain valid and are inferred back into the effective option list wherever the same schema declaration applies.
171
190
 
172
191
  Use descriptor `optionColors` to assign a palette color per option. With writable annotations, the menu shows a palette button beside each option and persists the selected color through the annotation host callback.
@@ -181,7 +200,7 @@ JSON Views descriptors are not JSON Schema. The packaged `@script-it/json-views-
181
200
 
182
201
  `JSONContent` includes `JsonViewsSurface` and scopes its styles and presentation state to that instance. State is in-memory unless the host supplies the presentation-state callbacks above. Navigation and hidden columns are never stored in a module-global cache. Each `JsonViewsProvider` also owns independent default registries. No host Tailwind installation is required. Use `theme="light"` or `theme="dark"` for explicit sibling themes; `inherit` reads the nearest host `data-json-views-theme`, `.light`, or `.dark`, falling back to light. It does not independently choose an OS theme.
183
202
 
184
- Override semantic tokens on a containing element with `--json-views-background`, `--json-views-foreground`, `--json-views-card`, `--json-views-border`, `--json-views-muted`, `--json-views-accent`, `--json-views-primary`, `--json-views-ring`, their foreground companions, and `--json-views-font-family`. Popups receive the same theme and tokens in a per-instance portal root. Use `portalContainer` for a host modal/overlay boundary; the supplied element should belong to the same document. `JsonViewsSurface` is exported for compositions using lower-level components.
203
+ Override semantic tokens on a containing element with `--json-views-background`, `--json-views-foreground`, `--json-views-card`, `--json-views-border`, `--json-views-muted`, `--json-views-accent`, `--json-views-primary`, `--json-views-ring`, their foreground companions, `--json-views-font-family`, and `--json-views-table-cell-max-height`. Popups receive the same theme and tokens in a per-instance portal root. Use `portalContainer` for a host modal/overlay boundary; the supplied element should belong to the same document. `JsonViewsSurface` is exported for compositions using lower-level components.
185
204
 
186
205
  ```css
187
206
  .product-json-viewer {
@@ -229,7 +248,9 @@ Accepted values are `null`, `YYYY-MM-DD`, and ISO 8601 date-times with seconds.
229
248
 
230
249
  ### Editor behavior
231
250
 
232
- Clicking a date opens its calendar immediately. Day, time, and timezone changes remain in a draft until Save; Cancel and Escape discard the draft. Enabling time requires a valid hour and minute and saves seconds. Floating date-times remain timezone-less unless the user chooses a timezone. Changing between timezone offsets preserves the instant; adding or removing a timezone preserves the displayed wall-clock time. Disabling time warns before saving the calendar date alone.
251
+ Enter saves a field and Escape cancels it; leaving a field also saves. In multiline text, Shift+Enter inserts a line break. Long-form Markdown, body, and HTML fields keep Enter for line breaks and save on Ctrl/Cmd+Enter. On mobile, where soft keyboards have no Shift+Enter, Return always inserts a line break in multiline fields, and leaving the field or Ctrl/Cmd+Enter saves.
252
+
253
+ Clicking a date opens its calendar immediately. Day, time, and timezone changes remain in a draft until Save, or Enter in the time field; Cancel and Escape discard the draft. Enabling time requires a valid hour and minute and saves seconds. Floating date-times remain timezone-less unless the user chooses a timezone. Changing between timezone offsets preserves the instant; adding or removing a timezone preserves the displayed wall-clock time. Disabling time warns before saving the calendar date alone.
233
254
 
234
255
  Clear is disabled for required fields. Optional dates default to `null`; a host can remove the property instead:
235
256
 
@@ -279,7 +300,7 @@ A view with `display: "html"` contains its HTML and CSS in the same `$jsonviews.
279
300
 
280
301
  - `jv-value` element: display a scalar value without editing.
281
302
  - `jv-field` element: display a scalar value and open the existing schema/registry field editor when clicked. Shared editors appear in the product layer, outside the authored CSS.
282
- - `jv-bind` on input or textarea: edit the source value. Supported inputs are text, number, range, and checkbox. Range commits at pointer/keyboard completion; text and number commit on blur or Enter; textarea uses blur or Ctrl/Cmd+Enter. Escape cancels. A failed native edit can be retried by completing the interaction again.
303
+ - `jv-bind` on input or textarea: edit the source value. Supported inputs are text, number, range, and checkbox. Range commits at pointer/keyboard completion; text, number, and textarea commit on blur or Enter, and Shift+Enter inserts a line break in a textarea (on mobile, Return inserts it and Ctrl/Cmd+Enter commits). Escape cancels. A failed native edit can be retried by completing the interaction again.
283
304
  - `jv-value` attribute on progress/meter: bind the numeric value property.
284
305
  - `jv-attr-<attribute>`: read a scalar into an allowed attribute: title, aria-label, aria-valuenow, aria-valuetext, x/y/cx/cy/x1/x2/y1/y2/width/height/r/rx/ry/fill/stroke/opacity. URLs are not permitted in attributes.
285
306
  - `jv-repeat source="$.tasks" as="task" key="id"`: repeat over an array. Aliases use ordinary path suffixes (`task.title`, `task['odd.key']`); nested repeats can reference outer aliases. Keys must be unique strings or finite numbers. Unkeyed arrays can display/edit against an unchanged acknowledged document, but have no stable row identity across reorder.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { CSVContent, JSONContent, StructuredDataContent, type CSVContentProps, type JSONContentProps, type JsonViewClearBehavior, type MetadataPersistence, type MetadataPersistenceRequest, type ObjectRootConversionRequest, type StructuredDataContentProps, } from './json/json-content.js';
2
2
  export { JsonViewer, type JsonViewJsonEditing } from './json/json-view.js';
3
+ export type { JsonViewsTableCellOptions } from './structured-data/table-cell-options.js';
3
4
  export { JsonViewsProvider, JsonViewWidgetRegistry, createDefaultWidgetRegistry, useJsonViewsRegistries, type JsonViewEditWidget, type JsonViewEditWidgetProps, type JsonViewDisplayWidget, type JsonViewDisplayWidgetProps, type JsonViewWidgetDefinition, type JsonViewWidgetRegistration, type JsonViewsRegistryValue, } from './widget-registry.js';
4
5
  export { DateValueDisplay, DateValueEditor } from './widgets/date-picker-widget.js';
5
6
  export { InlineFeedbackAction } from './primitives/inline-feedback-action.js';
@@ -0,0 +1,5 @@
1
+ /** Explicit, text-only presentation. Never evaluates template expressions. */
2
+ export declare function formatHtmlValue(value: unknown, attrs: Record<string, string>, resolve: (binding: string) => unknown): string | undefined;
3
+ /** The editor shows human units; the source retains its API encoding. */
4
+ export declare function htmlValueForEditing(value: unknown, attrs: Record<string, string>, resolve?: (binding: string) => unknown): unknown;
5
+ export declare function htmlValueFromEditing(value: unknown, attrs: Record<string, string>, resolve?: (binding: string) => unknown): unknown;
@@ -0,0 +1,84 @@
1
+ /** Explicit, text-only presentation. Never evaluates template expressions. */
2
+ export function formatHtmlValue(value, attrs, resolve) {
3
+ if (attrs.labels) {
4
+ const labels = JSON.parse(attrs.labels);
5
+ if (!labels || typeof labels !== 'object' || Array.isArray(labels) || Object.values(labels).some(label => typeof label !== 'string'))
6
+ throw new Error('labels must be a JSON object of string labels');
7
+ if (Object.hasOwn(labels, String(value)))
8
+ return labels[String(value)];
9
+ }
10
+ if (!attrs.format)
11
+ return undefined;
12
+ if (value == null)
13
+ return '—';
14
+ if (attrs.format === 'currency-minor') {
15
+ if (typeof value !== 'number' || !Number.isSafeInteger(value))
16
+ throw new Error('currency-minor requires a safe integer');
17
+ const formatter = currencyFormatter(attrs, resolve);
18
+ return formatter.format(value / 10 ** (formatter.resolvedOptions().maximumFractionDigits ?? 2));
19
+ }
20
+ if (['date', 'time', 'datetime'].includes(attrs.format)) {
21
+ const unit = attrs['date-unit'] ?? 'iso';
22
+ let date;
23
+ if (unit === 'seconds' || unit === 'milliseconds') {
24
+ if (!(typeof value === 'number' || typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)))
25
+ throw new Error('Timestamp must be numeric');
26
+ date = new Date(Number(value) * (unit === 'seconds' ? 1000 : 1));
27
+ }
28
+ else if (unit === 'iso' && typeof value === 'string' && /^\d{4}-\d{2}-\d{2}(T.*)?$/.test(value))
29
+ date = new Date(value);
30
+ else
31
+ throw new Error('Dates require an ISO value or an explicit timestamp unit');
32
+ if (!Number.isFinite(date.getTime()))
33
+ throw new Error('Invalid date');
34
+ return new Intl.DateTimeFormat('en-US', {
35
+ timeZone: attrs['time-zone'] ?? 'UTC',
36
+ ...(attrs.format !== 'time' ? { month: 'short', day: 'numeric', ...(attrs.format === 'date' ? { year: 'numeric' } : {}) } : {}),
37
+ ...(attrs.format !== 'date' ? { hour: 'numeric', minute: '2-digit' } : {}),
38
+ }).format(date);
39
+ }
40
+ if (attrs.format === 'base64url') {
41
+ if (typeof value !== 'string' || !/^[\w-]*={0,2}$/.test(value) || value.replace(/=+$/, '').length % 4 === 1)
42
+ throw new Error('Invalid base64url text');
43
+ const bytes = Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), character => character.charCodeAt(0));
44
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
45
+ }
46
+ throw new Error(`Unsupported value format: ${attrs.format}`);
47
+ }
48
+ function currencyFormatter(attrs, resolve) {
49
+ const currency = attrs['currency-bind'] ? resolve(attrs['currency-bind']) : attrs.currency;
50
+ if (typeof currency !== 'string' || !/^[a-z]{3}$/i.test(currency))
51
+ throw new Error('currency-minor requires a currency code');
52
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency });
53
+ }
54
+ function minorUnitFactor(attrs, resolve) {
55
+ return 10 ** (currencyFormatter(attrs, resolve).resolvedOptions().maximumFractionDigits ?? 2);
56
+ }
57
+ /** The editor shows human units; the source retains its API encoding. */
58
+ export function htmlValueForEditing(value, attrs, resolve = () => undefined) {
59
+ if (attrs.format === 'base64url')
60
+ return formatHtmlValue(value, attrs, resolve);
61
+ if (attrs.format === 'currency-minor' && typeof value === 'number')
62
+ return value / minorUnitFactor(attrs, resolve);
63
+ return value;
64
+ }
65
+ export function htmlValueFromEditing(value, attrs, resolve = () => undefined) {
66
+ if (attrs.format === 'base64url') {
67
+ if (typeof value !== 'string')
68
+ throw new Error('Enter text');
69
+ const bytes = new TextEncoder().encode(value);
70
+ let binary = '';
71
+ for (const byte of bytes)
72
+ binary += String.fromCharCode(byte);
73
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
74
+ }
75
+ if (attrs.format === 'currency-minor') {
76
+ if (typeof value !== 'number' || !Number.isFinite(value))
77
+ throw new Error('Enter a finite amount');
78
+ const scaled = value * minorUnitFactor(attrs, resolve), rounded = Math.round(scaled);
79
+ if (!Number.isSafeInteger(rounded) || Math.abs(scaled - rounded) > 1e-7)
80
+ throw new Error('Amount has unsupported precision');
81
+ return rounded;
82
+ }
83
+ return value;
84
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ValuePath } from '@script-it/json-views-core';
2
+ import { type EnterKeyEvent } from '../../lib/enter-key.js';
2
3
  import type { JsonViewJsonEditing } from '../view-types.js';
3
4
  export declare function NativeHtmlControl({ tag, props, path, value, editing, report }: {
4
5
  tag: string;
@@ -16,10 +17,7 @@ export declare function NativeHtmlControl({ tag, props, path, value, editing, re
16
17
  onBlur: () => void;
17
18
  onPointerUp: () => void;
18
19
  onKeyUp: () => void;
19
- onKeyDown: (event: {
20
- key: string;
21
- ctrlKey: boolean;
22
- metaKey: boolean;
20
+ onKeyDown: (event: EnterKeyEvent & {
23
21
  preventDefault: () => void;
24
22
  }) => void;
25
23
  checked: boolean;
@@ -33,10 +31,7 @@ export declare function NativeHtmlControl({ tag, props, path, value, editing, re
33
31
  onBlur: () => void;
34
32
  onPointerUp: () => void;
35
33
  onKeyUp: () => void;
36
- onKeyDown: (event: {
37
- key: string;
38
- ctrlKey: boolean;
39
- metaKey: boolean;
34
+ onKeyDown: (event: EnterKeyEvent & {
40
35
  preventDefault: () => void;
41
36
  }) => void;
42
37
  value: string;
@@ -1,5 +1,6 @@
1
1
  import { createElement, useContext, useEffect, useRef, useState } from 'react';
2
2
  import { EditBaseContext } from '../../structured-data/edit-base.js';
3
+ import { submitsOnEnter, useEnterKeyBehavior } from '../../lib/enter-key.js';
3
4
  export function NativeHtmlControl({ tag, props, path, value, editing, report }) {
4
5
  const base = useContext(EditBaseContext);
5
6
  const [draft, setDraft] = useState(value == null ? '' : String(value));
@@ -8,6 +9,7 @@ export function NativeHtmlControl({ tag, props, path, value, editing, report })
8
9
  const original = useRef({ base, path });
9
10
  const pending = useRef(false), changed = useRef(false), draftRef = useRef(draft);
10
11
  const type = props.type ?? 'text';
12
+ const enterKey = useEnterKeyBehavior();
11
13
  useEffect(() => { if (!active) {
12
14
  const next = value == null ? '' : String(value);
13
15
  setDraft(next);
@@ -87,7 +89,7 @@ export function NativeHtmlControl({ tag, props, path, value, editing, report })
87
89
  cancel();
88
90
  report('');
89
91
  }
90
- if (event.key === 'Enter' && (tag !== 'textarea' || event.ctrlKey || event.metaKey)) {
92
+ if (submitsOnEnter(event, { multiline: tag === 'textarea', enterKey })) {
91
93
  event.preventDefault();
92
94
  void commit();
93
95
  }
@@ -2,6 +2,7 @@ const tags = new Set('main article section header footer nav aside div span h1 h
2
2
  const dangerous = new Set('script iframe object embed base meta link form foreignobject animate animatemotion animatetransform set style'.split(' '));
3
3
  export const boundAttributes = new Set('title aria-label aria-valuenow aria-valuetext x y cx cy x1 x2 y1 y2 width height r rx ry fill stroke opacity'.split(' '));
4
4
  const attributes = new Set('id class title role type min max step placeholder rows cols maxlength disabled readonly tabindex for value checked max low high optimum width height viewBox preserveAspectRatio d x y cx cy x1 x2 y1 y2 r rx ry points fill stroke stroke-width opacity text-anchor font-size font-family alt bind source as key'.toLowerCase().split(' '));
5
+ const valueAttributes = new Set('format labels currency currency-bind date-unit time-zone sync-bind byte-length-bind'.split(' '));
5
6
  export function parseHtmlTemplate(doc, html) {
6
7
  const template = doc.createElement('template');
7
8
  // Template contents are inert; parsed only in the CSP-restricted frame document.
@@ -29,6 +30,10 @@ export function parseHtmlTemplate(doc, html) {
29
30
  const attrs = {};
30
31
  for (const attr of Array.from(element.attributes)) {
31
32
  const name = attr.name.toLowerCase(), value = attr.value;
33
+ if ((tag === 'jv-value' || tag === 'jv-field') && valueAttributes.has(name) || tag === 'jv-repeat' && name === 'order') {
34
+ attrs[name] = value;
35
+ continue;
36
+ }
32
37
  if (name === 'src' && tag === 'img' && /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(value) && value.length <= 256 * 1024) {
33
38
  attrs.src = value;
34
39
  continue;
@@ -10,6 +10,7 @@ import { SchemaEditor } from './schema-value.js';
10
10
  import { useJsonSourceLiterals } from './source-literals.js';
11
11
  import { parseHtmlTemplate } from './html-view/template.js';
12
12
  import { NativeHtmlControl } from './html-view/native-control.js';
13
+ import { formatHtmlValue, htmlValueForEditing, htmlValueFromEditing } from './html-view/format-value.js';
13
14
  const skeleton = `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"><style>body{margin:0;font:14px system-ui;background:var(--jv-background);color:var(--jv-foreground)}*{box-sizing:border-box}jv-field,jv-value{display:inline}button.jv-field{font:inherit;color:inherit;background:transparent;border:0;padding:0;text-align:inherit;cursor:pointer}button.jv-field:hover{outline:1px dashed currentColor}button.jv-field:focus-visible{outline:2px solid currentColor}</style></head><body><div id="mount"></div></body></html>`;
14
15
  export function HtmlView({ compiled, view, editing, fillHeight, onCurrentPathChange }) {
15
16
  const frame = useRef(null), editorRef = useRef(null);
@@ -101,7 +102,12 @@ export function HtmlView({ compiled, view, editing, fillHeight, onCurrentPathCha
101
102
  if (!Array.isArray(resolved.value))
102
103
  throw new Error('jv-repeat source must be an array');
103
104
  const keys = htmlRepeatKeys(resolved.value, node.attrs.key);
104
- return resolved.value.map((_, index) => _jsx(Fragment, { children: render(node.children, { ...aliases, [alias]: [...resolved.sourcePath, index] }) }, `${node.id}:${keys[index]}`));
105
+ if (node.attrs.order && !['forward', 'reverse'].includes(node.attrs.order))
106
+ throw new Error('Repeat order must be forward or reverse');
107
+ const indices = resolved.value.map((_, index) => index);
108
+ if (node.attrs.order === 'reverse')
109
+ indices.reverse();
110
+ return indices.map(index => _jsx(Fragment, { children: render(node.children, { ...aliases, [alias]: [...resolved.sourcePath, index] }) }, `${node.id}:${keys[index]}`));
105
111
  }
106
112
  const props = { key: node.id };
107
113
  const mapping = { class: 'className', for: 'htmlFor', tabindex: 'tabIndex', readonly: 'readOnly', maxlength: 'maxLength', viewbox: 'viewBox', preserveaspectratio: 'preserveAspectRatio', 'stroke-width': 'strokeWidth', 'text-anchor': 'textAnchor', 'font-size': 'fontSize', 'font-family': 'fontFamily' };
@@ -119,9 +125,16 @@ export function HtmlView({ compiled, view, editing, fillHeight, onCurrentPathCha
119
125
  const resolved = resolve(node.attrs.bind ?? '', aliases), value = resolved.value;
120
126
  if (value !== null && typeof value === 'object')
121
127
  throw new Error('Use jv-repeat for arrays and objects');
122
- const text = literals(resolved.sourcePath) ?? (value == null ? '—' : String(value));
123
- const canEdit = node.tag === 'jv-field' && editing && editing.canReplace?.(resolved.sourcePath) !== false;
124
- return _jsx("span", { children: canEdit ? _jsx("button", { className: "jv-field", "aria-label": `Edit ${node.attrs.bind}`, onClick: event => setSelected({ path: resolved.sourcePath, anchor: event.currentTarget, base }), children: text }) : text }, node.id);
128
+ const resolveValue = (binding) => resolve(binding, aliases).value;
129
+ const text = formatHtmlValue(value, node.attrs, resolveValue) ?? literals(resolved.sourcePath) ?? (value == null ? '—' : String(value));
130
+ const sync = node.attrs['sync-bind'] ? resolve(node.attrs['sync-bind'], aliases) : undefined;
131
+ const byteLength = node.attrs['byte-length-bind'] ? resolve(node.attrs['byte-length-bind'], aliases) : undefined;
132
+ if (sync && sync.value !== null && typeof sync.value === 'object')
133
+ throw new Error('sync-bind must address a scalar');
134
+ if (byteLength && (node.attrs.format !== 'base64url' || typeof byteLength.value !== 'number'))
135
+ throw new Error('byte-length-bind requires base64url and a numeric size field');
136
+ const canEdit = node.tag === 'jv-field' && editing && [resolved, sync, byteLength].every(target => !target || editing.canReplace?.(target.sourcePath) !== false);
137
+ return _jsx("span", { children: canEdit ? _jsx("button", { className: "jv-field", "aria-label": node.attrs['aria-label'] ?? `Edit ${node.attrs.bind}`, onClick: event => setSelected({ path: resolved.sourcePath, anchor: event.currentTarget, base, attrs: node.attrs, resolve: resolveValue, syncPath: sync?.sourcePath, byteLengthPath: byteLength?.sourcePath }), children: text }) : text }, node.id);
125
138
  }
126
139
  if (node.attrs['jv-bind']) {
127
140
  if (node.tag !== 'input' && node.tag !== 'textarea')
@@ -156,8 +169,37 @@ export function HtmlView({ compiled, view, editing, fillHeight, onCurrentPathCha
156
169
  const descriptor = selected ? schemaForJsonViewPath(compiled.schema, selected.path)?.descriptor ?? { type: typeof selectedValue === 'number' ? 'number' : typeof selectedValue === 'boolean' ? 'checkbox' : 'text' } : undefined;
157
170
  // Text too long for one line gets the resizable multi-line control, matching
158
171
  // how tables and records present the same value.
159
- const editorDescriptor = descriptor?.type === 'text' && isLongText(selectedValue, descriptor) ? { ...descriptor, multiline: true } : descriptor;
160
- return _jsxs("div", { className: "flex min-h-0 flex-1 flex-col", "data-id": "jsonView-html-view", children: [(error || warnings.length > 0) && _jsxs("div", { role: "alert", className: "border-b p-2 text-xs", children: [error || warnings.slice(0, 10).join(' · '), error && _jsx("button", { onClick: () => setError(''), className: "ml-2 underline", children: "Dismiss" })] }), _jsx("iframe", { ref: frame, title: view.name, sandbox: "allow-same-origin", srcDoc: skeleton, className: "w-full flex-1 border-0", style: { minHeight: fillHeight ? 200 : height, height: fillHeight ? '100%' : height }, onLoad: event => { setDoc(event.currentTarget.contentDocument ?? undefined); setSelected(undefined); } }), doc?.getElementById('mount') && createPortal(content, doc.getElementById('mount')), selected && editorDescriptor && editing && portal && createPortal(_jsx("div", { ref: editorRef, "data-anchored-popup": true, className: "z-[120] w-80 rounded-md border bg-card p-3 text-card-foreground shadow-lg", style: position, children: _jsx(EditBaseContext.Provider, { value: base, children: _jsx(SchemaEditor, { descriptor: editorDescriptor, disabled: editing.saving, label: String(selected.path.at(-1) ?? 'value'), variant: "default", value: selectedValue, onCancel: () => { const anchor = selected.anchor; setSelected(undefined); if (anchor.isConnected)
161
- anchor.focus(); }, onCommit: async (value) => { if (selected.base !== base)
162
- throw new Error('This document changed outside this editor. Cancel and reopen it before saving.'); await editing.replace(selected.path, value); } }, `${view.id}:${JSON.stringify(selected.path)}`) }) }), portal)] });
172
+ let editorValue = selectedValue;
173
+ let formattedDescriptor = selected?.attrs.format === 'base64url' ? { type: 'text', multiline: true } : descriptor;
174
+ try {
175
+ if (selected)
176
+ editorValue = htmlValueForEditing(selectedValue, selected.attrs, selected.resolve);
177
+ if (selected?.attrs.format === 'currency-minor' && descriptor) {
178
+ formattedDescriptor = { ...descriptor };
179
+ for (const key of ['minimum', 'maximum', 'step']) {
180
+ if (typeof descriptor[key] === 'number')
181
+ formattedDescriptor[key] = htmlValueForEditing(descriptor[key], selected.attrs, selected.resolve);
182
+ }
183
+ }
184
+ }
185
+ catch {
186
+ // A source change can invalidate an open field. Its stale-save guard still
187
+ // rejects the draft; rendering that state must not crash the whole view.
188
+ }
189
+ const editorDescriptor = formattedDescriptor?.type === 'text' && isLongText(editorValue, formattedDescriptor) ? { ...formattedDescriptor, multiline: true } : formattedDescriptor;
190
+ return _jsxs("div", { className: "flex min-h-0 flex-1 flex-col", "data-id": "jsonView-html-view", children: [(error || warnings.length > 0) && _jsxs("div", { role: "alert", className: "border-b p-2 text-xs", children: [error || warnings.slice(0, 10).join(' · '), error && _jsx("button", { onClick: () => setError(''), className: "ml-2 underline", children: "Dismiss" })] }), _jsx("iframe", { ref: frame, title: view.name, sandbox: "allow-same-origin", srcDoc: skeleton, className: "w-full flex-1 border-0", style: { minHeight: fillHeight ? 200 : height, height: fillHeight ? '100%' : height }, onLoad: event => { setDoc(event.currentTarget.contentDocument ?? undefined); setSelected(undefined); } }), doc?.getElementById('mount') && createPortal(content, doc.getElementById('mount')), selected && editorDescriptor && editing && portal && createPortal(_jsx("div", { ref: editorRef, "data-anchored-popup": true, className: "z-[120] w-80 rounded-md border bg-card p-3 text-card-foreground shadow-lg", style: position, children: _jsx(EditBaseContext.Provider, { value: base, children: _jsx(SchemaEditor, { descriptor: editorDescriptor, disabled: editing.saving, label: selected.attrs['aria-label'] ?? String(selected.path.at(-1) ?? 'value'), variant: "default", value: editorValue, onCancel: () => { const anchor = selected.anchor; setSelected(undefined); if (anchor.isConnected)
191
+ anchor.focus(); }, onCommit: async (value) => {
192
+ if (selected.base !== base)
193
+ throw new Error('This document changed outside this editor. Cancel and reopen it before saving.');
194
+ const encoded = htmlValueFromEditing(value, selected.attrs, selected.resolve);
195
+ const replacements = [{ path: selected.path, value: encoded }];
196
+ if (selected.syncPath)
197
+ replacements.push({ path: selected.syncPath, value: encoded });
198
+ if (selected.byteLengthPath)
199
+ replacements.push({ path: selected.byteLengthPath, value: new TextEncoder().encode(String(value)).length });
200
+ if (replacements.length > 1)
201
+ await editing.replaceMany(replacements);
202
+ else
203
+ await editing.replace(selected.path, encoded);
204
+ } }, `${view.id}:${JSON.stringify(selected.path)}`) }) }), portal)] });
163
205
  }
@@ -4,6 +4,7 @@ import { type JsonDocumentSave, type StructuredDocumentAdapter, type ValuePath }
4
4
  import type { UiSize } from '../lib/ui-size.js';
5
5
  import type { FileContentEditState } from '../file-content-edit-state.js';
6
6
  import { type MetadataPersistence } from '../structured-data/use-metadata-persistence.js';
7
+ import type { JsonViewsTableCellOptions } from '../structured-data/table-cell-options.js';
7
8
  import type { JsonViewSchemaExternalWidgetRenderer } from './schema-value.js';
8
9
  import type { JsonViewsPresentationState } from '../viewer-state.js';
9
10
  export type JsonViewClearBehavior = 'null' | 'remove';
@@ -46,6 +47,8 @@ export interface StructuredDataContentProps {
46
47
  sourceVisible?: boolean;
47
48
  onSourceVisibleChange?: (visible: boolean) => void;
48
49
  clearBehavior?: JsonViewClearBehavior;
50
+ /** Clamps tall table cells and reveals one on selection (default), or lets rows grow to fit. */
51
+ tableCells?: JsonViewsTableCellOptions;
49
52
  onDraftRowsChange?: (paths: readonly ValuePath[]) => void;
50
53
  renderMarkdown?: (content: string) => ReactNode;
51
54
  renderExternalWidget?: JsonViewSchemaExternalWidgetRenderer;
@@ -46,25 +46,31 @@ export function StructuredDataContent(props) {
46
46
  return _jsx(JsonViewsSurface, { theme: props.theme, portalContainer: props.portalContainer, presentationState: props.presentationState, onPresentationStateChange: props.onPresentationStateChange, children: _jsx(StructuredDataContentSession, { ...props }, `${props.documentId ?? props.path ?? 'document'}:${props.adapter.format}`) }, props.documentId ?? props.path ?? 'document');
47
47
  }
48
48
  export function JSONContent(props) {
49
- let adapter = jsonObjectFormat;
50
49
  // The editable draft is the current document while the source editor is open.
51
50
  // Hosts may keep `content` at the last persisted revision until a save finishes,
52
51
  // so choosing the adapter from it would incorrectly reject a valid root-shape
53
52
  // change (for example, an object replaced with an array).
54
- try {
55
- adapter = jsonFormatForSource(props.edit?.isEditing ? props.edit.editContent : props.content);
56
- }
57
- catch { /* Recovery handles invalid JSON. */ }
53
+ const source = props.edit?.isEditing ? props.edit.editContent : props.content;
54
+ // Choosing the adapter parses the whole document; hosts re-render far more often than the text changes.
55
+ const adapter = useMemo(() => {
56
+ try {
57
+ return jsonFormatForSource(source);
58
+ }
59
+ catch { /* Recovery handles invalid JSON. */ }
60
+ return jsonObjectFormat;
61
+ }, [source]);
58
62
  return _jsx(StructuredDataContent, { ...props, adapter: adapter });
59
63
  }
60
64
  export function CSVContent(props) {
61
65
  return _jsx(StructuredDataContent, { ...props, adapter: csvFormat });
62
66
  }
63
- function StructuredDataContentSession({ content, adapter, documentId, revision, metadata, metadataPersistence, presentationState, onMetadataChange, onRequestMetadataPersistence, metadataConversionConfirmation = 'viewer', allowRootArrayWrapping = true, path, uiSize, fillHeight = false, edit, onSave, isSaving, isDirty, saveError, sourceVisible, onSourceVisibleChange, clearBehavior = 'null', onDraftRowsChange, renderExternalWidget, renderMarkdown, }) {
67
+ function StructuredDataContentSession({ content, adapter, documentId, revision, metadata, metadataPersistence, presentationState, onMetadataChange, onRequestMetadataPersistence, metadataConversionConfirmation = 'viewer', allowRootArrayWrapping = true, path, uiSize, fillHeight = false, edit, onSave, isSaving, isDirty, saveError, sourceVisible, onSourceVisibleChange, clearBehavior = 'null', tableCells, onDraftRowsChange, renderExternalWidget, renderMarkdown, }) {
64
68
  const { types } = useJsonViewsRegistries();
65
69
  const [editorGeneration, setEditorGeneration] = useState(0);
66
70
  const [draftRows, setDraftRows] = useState([]);
67
- const [localSourceVisible, setLocalSourceVisible] = useState(() => presentationState?.activeView ? presentationState.activeView === 'source' : adapter.format !== 'csv' && prefersSource(content, metadata));
71
+ // A host that controls `sourceVisible` never reads the local flag, so the default-view heuristic can be skipped.
72
+ const [localSourceVisible, setLocalSourceVisible] = useState(() => sourceVisible === undefined
73
+ && (presentationState?.activeView ? presentationState.activeView === 'source' : adapter.format !== 'csv' && prefersSource(content, metadata)));
68
74
  const [currentViewPaths, setCurrentViewPaths] = useState([[]]);
69
75
  const rememberSourcePaths = useCallback((path, paths) => {
70
76
  setCurrentViewPaths(paths ?? [path]);
@@ -251,7 +257,7 @@ function StructuredDataContentSession({ content, adapter, documentId, revision,
251
257
  const rawView = views[viewDeclarationIndex];
252
258
  if (!isRecord(rawView))
253
259
  throw new Error('This table view cannot be updated');
254
- const existingView = compileJsonViewMetadata(root).views.find((view) => view.declarationIndex === viewDeclarationIndex);
260
+ const existingView = compileJsonViewMetadata(root, undefined, { validateValues: false }).views.find((view) => view.declarationIndex === viewDeclarationIndex);
255
261
  const columns = Array.isArray(rawView.columns) && rawView.columns.length > 0 ? [...rawView.columns]
256
262
  : existingView ? projectJsonViewCollection(root, { ...existingView, filter: undefined, sort: [] }).columns.map((column) => ({ label: column.label, path: column.path.source })) : [];
257
263
  views[viewDeclarationIndex] = { ...rawView, columns: [...columns, { label: typeof descriptor.title === 'string' ? descriptor.title : property, path: schemaPath }] };
@@ -315,7 +321,7 @@ function StructuredDataContentSession({ content, adapter, documentId, revision,
315
321
  if (!metadataWritable || !compiled)
316
322
  throw new Error('These annotations cannot be edited');
317
323
  const next = withOptionColor(currentMetadata(), schemaPath, optionValue, color);
318
- const candidate = compileJsonViewMetadata(parsed.root, types, { metadata: next });
324
+ const candidate = compileJsonViewMetadata(parsed.root, types, { metadata: next, validateValues: false });
319
325
  if (!candidate.schema.some((entry) => entry.declaration === schemaPath)) {
320
326
  throw new Error(candidate.diagnostics.find((item) => item.scope === 'schema')?.message ?? 'Invalid option color');
321
327
  }
@@ -344,7 +350,7 @@ function StructuredDataContentSession({ content, adapter, documentId, revision,
344
350
  ...(base.schema === undefined && compiled.inference && Object.keys(compiled.inference.schema).length > 0 ? { schema: compiled.inference.schema } : {}),
345
351
  views,
346
352
  };
347
- const candidate = compileJsonViewMetadata(parsed.root, types, { metadata: next });
353
+ const candidate = compileJsonViewMetadata(parsed.root, types, { metadata: next, validateValues: false });
348
354
  if (candidate.views.length !== views.length)
349
355
  throw new Error(candidate.diagnostics.find((item) => item.scope === 'view')?.message ?? 'Invalid view');
350
356
  if (persistence === 'session') {
@@ -380,8 +386,9 @@ function StructuredDataContentSession({ content, adapter, documentId, revision,
380
386
  };
381
387
  const saveStatus = _jsx(ViewSaveStatus, { embedded: embedded, objectRoot: adapter.format === 'json-object' && isRecord(parsed.root), conversionConfirmation: metadataConversionConfirmation, saving: document.saving, dirty: isDirty || document.dirty, invalidSourceError: parsed.error ?? undefined, invalidFormat: parsed.error ? adapter.format === 'csv' ? 'CSV' : 'JSON' : undefined, error: document.error || saveError || (!metadataWritable ? 'View settings need repair before saving.' : undefined), onSave: document.canCommit && metadataWritable && (isRecord(parsed.root) && adapter.format === 'json-object' || onRequestMetadataPersistence) ? saveViewsToSource : undefined });
382
388
  const sourceFormat = adapter.format === 'csv' ? 'CSV' : 'JSON';
389
+ // Formatting and target ranges are only consumed by the source panel, so they wait until it is shown.
383
390
  const formattedSource = useMemo(() => {
384
- if (adapter.format === 'csv')
391
+ if (adapter.format === 'csv' || !showingSource)
385
392
  return undefined;
386
393
  try {
387
394
  return formatJsonSource(document.content);
@@ -389,12 +396,12 @@ function StructuredDataContentSession({ content, adapter, documentId, revision,
389
396
  catch {
390
397
  return undefined;
391
398
  }
392
- }, [adapter.format, document.content]);
399
+ }, [adapter.format, document.content, showingSource]);
393
400
  const formatSource = !document.canCommit || formattedSource === undefined || formattedSource === document.content.trim()
394
401
  ? undefined : () => editSource(formattedSource);
395
402
  const sourceControl = _jsxs(_Fragment, { children: [showingSource && formatSource && _jsx(SourceFormatButton, { format: sourceFormat, disabled: document.saving, onFormat: formatSource }), saveStatus] });
396
403
  const sourceTargetRanges = useMemo(() => {
397
- if (adapter.format === 'csv' || currentViewPaths.length === 0 || coversAllDocumentData(parsed.root, currentViewPaths))
404
+ if (adapter.format === 'csv' || !showingSource || currentViewPaths.length === 0 || coversAllDocumentData(parsed.root, currentViewPaths))
398
405
  return [];
399
406
  try {
400
407
  return jsonSourceRangesAtPaths(document.content, currentViewPaths);
@@ -402,7 +409,7 @@ function StructuredDataContentSession({ content, adapter, documentId, revision,
402
409
  catch {
403
410
  return [];
404
411
  }
405
- }, [adapter.format, currentViewPaths, document.content, parsed.root]);
412
+ }, [adapter.format, currentViewPaths, document.content, parsed.root, showingSource]);
406
413
  const sourceEditor = (_jsx(TooltipProvider, { children: _jsx(SourceEditor, { embedded: !parsed.error, saveStatus: saveStatus, content: document.content, dirty: isDirty ?? document.dirty, fillHeight: fillHeight, format: sourceFormat, invalidSourceError: parsed.error, onChange: editSource, onClose: () => setShowingSource(false), onFormat: formatSource, readOnly: !document.canCommit, saveError: document.error ?? saveError, saving: document.saving, targetRanges: sourceTargetRanges }) }));
407
414
  if (parsed.error)
408
415
  return sourceEditor;
@@ -429,5 +436,5 @@ function StructuredDataContentSession({ content, adapter, documentId, revision,
429
436
  replace: replaceValue, replaceMany: replaceValues, ...(metadataWritable ? { setOptionColor } : {}), replaceViews,
430
437
  removeMany: removeValues, saving: document.saving,
431
438
  } : undefined;
432
- return (_jsxs(TooltipProvider, { children: [document.error && _jsxs("div", { className: "grid gap-2 px-4 py-2 text-sm", children: [_jsx("p", { role: "alert", className: "text-destructive", children: document.error }), document.dirty && _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx("button", { type: "button", disabled: document.saving, className: "rounded-md border border-border px-2 py-1 disabled:opacity-50", onClick: () => { void document.commit((source) => source).catch(() => { }); }, children: "Retry save" }), _jsx("button", { type: "button", disabled: document.saving, className: "rounded-md border border-border px-2 py-1 disabled:opacity-50", onClick: () => { document.reset(); setEditorGeneration((generation) => generation + 1); }, children: "Discard unsaved changes" }), _jsxs("details", { className: "basis-full", children: [_jsx("summary", { children: "Unsaved source" }), _jsx("pre", { className: "max-h-64 overflow-auto whitespace-pre-wrap font-mono text-xs", children: document.content })] })] })] }), formatDiagnostics.length > 0 && _jsxs("details", { className: "px-4 py-2 text-sm", children: [_jsx("summary", { children: formatDiagnostics.some((item) => item.severity === 'error') ? 'This document needs repair before structured editing.' : 'Some values require source editing to preserve their exact representation.' }), _jsx(JsonSourceRecovery, { source: document.content, error: formatDiagnostics.map((item) => item.message).join(' '), onCommit: recoveryCommit })] }), _jsx(JsonRendererBoundary, { source: document.content, onCommit: recoveryCommit, children: _jsx(JsonSourceLiteralsProvider, { diagnostics: sourceDiagnostics, children: _jsx(EditBaseContext.Provider, { value: document.acknowledgedContent, children: _jsx(JsonViewer, { compiled: compiled, defaultKey: path?.split('/').pop() || 'root', filePath: documentId ?? path, sourceContent: sourceEditor, sourceVisible: showingSource, onSourceVisibleChange: setShowingSource, tabularRoot: adapter.format === 'csv', editing: editing, onCurrentPathChange: rememberSourcePaths, renderExternalWidget: renderExternalWidget, renderMarkdown: renderMarkdown, sourceControl: sourceControl, uiSize: uiSize, fillHeight: fillHeight }) }) }) }, editorGeneration)] }));
439
+ return (_jsxs(TooltipProvider, { children: [document.error && _jsxs("div", { className: "grid gap-2 px-4 py-2 text-sm", children: [_jsx("p", { role: "alert", className: "text-destructive", children: document.error }), document.dirty && _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx("button", { type: "button", disabled: document.saving, className: "rounded-md border border-border px-2 py-1 disabled:opacity-50", onClick: () => { void document.commit((source) => source).catch(() => { }); }, children: "Retry save" }), _jsx("button", { type: "button", disabled: document.saving, className: "rounded-md border border-border px-2 py-1 disabled:opacity-50", onClick: () => { document.reset(); setEditorGeneration((generation) => generation + 1); }, children: "Discard unsaved changes" }), _jsxs("details", { className: "basis-full", children: [_jsx("summary", { children: "Unsaved source" }), _jsx("pre", { className: "max-h-64 overflow-auto whitespace-pre-wrap font-mono text-xs", children: document.content })] })] })] }), formatDiagnostics.length > 0 && _jsxs("details", { className: "px-4 py-2 text-sm", children: [_jsx("summary", { children: formatDiagnostics.some((item) => item.severity === 'error') ? 'This document needs repair before structured editing.' : 'Some values require source editing to preserve their exact representation.' }), _jsx(JsonSourceRecovery, { source: document.content, error: formatDiagnostics.map((item) => item.message).join(' '), onCommit: recoveryCommit })] }), _jsx(JsonRendererBoundary, { source: document.content, onCommit: recoveryCommit, children: _jsx(JsonSourceLiteralsProvider, { diagnostics: sourceDiagnostics, children: _jsx(EditBaseContext.Provider, { value: document.acknowledgedContent, children: _jsx(JsonViewer, { compiled: compiled, defaultKey: path?.split('/').pop() || 'root', filePath: documentId ?? path, sourceContent: sourceEditor, sourceVisible: showingSource, onSourceVisibleChange: setShowingSource, tabularRoot: adapter.format === 'csv', editing: editing, onCurrentPathChange: rememberSourcePaths, renderExternalWidget: renderExternalWidget, renderMarkdown: renderMarkdown, sourceControl: sourceControl, tableCells: tableCells, uiSize: uiSize, fillHeight: fillHeight }) }) }) }, editorGeneration)] }));
433
440
  }
@@ -1,3 +1,3 @@
1
1
  import { type JsonViewerProps } from './view-types.js';
2
- export declare function JsonViewer({ compiled, defaultKey, editing, filePath, fillHeight, onCurrentPathChange, renderExternalWidget, renderMarkdown, sourceControl, sourceContent, sourceVisible, onSourceVisibleChange, uiSize, viewsEnabled, tabularRoot, }: JsonViewerProps): import("react").JSX.Element;
2
+ export declare function JsonViewer({ compiled, defaultKey, editing, filePath, fillHeight, onCurrentPathChange, renderExternalWidget, renderMarkdown, sourceControl, sourceContent, sourceVisible, onSourceVisibleChange, tableCells, uiSize, viewsEnabled, tabularRoot, }: JsonViewerProps): import("react").JSX.Element;
3
3
  export type { JsonViewJsonEditing } from './view-types.js';