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

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 +27 -14
  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
@@ -18,6 +18,7 @@ import { GeneralRecordView, RecordView } from './record-view.js';
18
18
  import { ProjectedTableView } from './table-view.js';
19
19
  import { KanbanView } from './kanban-view.js';
20
20
  import { useViewerState } from '../viewer-state.js';
21
+ import { TableCellOptionsContext } from '../structured-data/table-cell-options.js';
21
22
  const VIEW_TAB_CLASS = 'h-7 shrink-0 gap-1.5 rounded-md px-2 py-0 text-xs font-medium text-muted-foreground shadow-none hover:bg-accent hover:text-foreground data-[state=active]:bg-accent data-[state=active]:text-foreground data-[state=active]:shadow-none [&>svg]:shrink-0';
22
23
  function slashPath(view) {
23
24
  const segments = view.path.segments.map((segment) => {
@@ -98,7 +99,7 @@ function ConfiguredView(props) {
98
99
  }
99
100
  return _jsx(AdaptiveView, { ...props });
100
101
  }
101
- export function JsonViewer({ compiled, defaultKey, editing, filePath, fillHeight = false, onCurrentPathChange, renderExternalWidget, renderMarkdown, sourceControl, sourceContent, sourceVisible, onSourceVisibleChange, uiSize, viewsEnabled = true, tabularRoot = false, }) {
102
+ export function JsonViewer({ compiled, defaultKey, editing, filePath, fillHeight = false, onCurrentPathChange, renderExternalWidget, renderMarkdown, sourceControl, sourceContent, sourceVisible, onSourceVisibleChange, tableCells, uiSize, viewsEnabled = true, tabularRoot = false, }) {
102
103
  const { types } = useJsonViewsRegistries();
103
104
  const viewerState = useViewerState();
104
105
  const firstViewTab = viewsEnabled && compiled.views[0] ? `view:${compiled.views[0].id}` : 'root';
@@ -146,11 +147,12 @@ export function JsonViewer({ compiled, defaultKey, editing, filePath, fillHeight
146
147
  }, [compiled.root, currentPath, defaultKey, rawViews]);
147
148
  const settingsRawView = activeRawView ?? rootDraftRawView;
148
149
  const settingsDeclarationIndex = declaredView?.declarationIndex ?? rawViews.length;
150
+ // Only the compiled view is needed here, so the per-value validation pass is skipped.
149
151
  const settingsView = useMemo(() => {
150
152
  if (declaredView)
151
153
  return declaredView;
152
154
  const candidate = compileJsonViewMetadata(compiled.root, types, {
153
- metadata: metadataWithViews(compiled, [...rawViews, rootDraftRawView]),
155
+ metadata: metadataWithViews(compiled, [...rawViews, rootDraftRawView]), validateValues: false,
154
156
  });
155
157
  return candidate.views.find((view) => view.declarationIndex === rawViews.length);
156
158
  }, [compiled, declaredView, rawViews, rootDraftRawView, types, types.version]);
@@ -169,7 +171,7 @@ export function JsonViewer({ compiled, defaultKey, editing, filePath, fillHeight
169
171
  views[declaredView.declarationIndex] = previewRawView;
170
172
  else
171
173
  views.push(previewRawView);
172
- const candidate = compileJsonViewMetadata(compiled.root, types, { metadata: metadataWithViews(compiled, views) });
174
+ const candidate = compileJsonViewMetadata(compiled.root, types, { metadata: metadataWithViews(compiled, views), validateValues: false });
173
175
  return candidate.views.find((view) => view.declarationIndex === settingsDeclarationIndex);
174
176
  }, [compiled, declaredView, previewRawView, rawViews, settingsDeclarationIndex, types, types.version]);
175
177
  const previewSettings = useCallback((next) => {
@@ -215,7 +217,7 @@ export function JsonViewer({ compiled, defaultKey, editing, filePath, fillHeight
215
217
  if (!editing)
216
218
  throw new Error('This JSON is read-only');
217
219
  if (requiredIndex !== undefined) {
218
- const candidate = compileJsonViewMetadata(compiled.root, types, { metadata: metadataWithViews(compiled, views) });
220
+ const candidate = compileJsonViewMetadata(compiled.root, types, { metadata: metadataWithViews(compiled, views), validateValues: false });
219
221
  const valid = candidate.views.some((view) => view.declarationIndex === requiredIndex);
220
222
  const issue = candidate.diagnostics.find((item) => item.scope === 'view' && item.metadataPath[2] === requiredIndex);
221
223
  if (!valid)
@@ -291,32 +293,32 @@ export function JsonViewer({ compiled, defaultKey, editing, filePath, fillHeight
291
293
  if (!query)
292
294
  setSearchOpenTab(undefined);
293
295
  };
294
- return (_jsx(Tabs, { value: selectedTab, onValueChange: (value) => { if (value !== 'source')
295
- setActiveTab(value); onSourceVisibleChange?.(value === 'source'); }, className: cn('flex min-h-0 flex-1 flex-col bg-background text-foreground', fillHeight && 'h-full'), children: _jsxs("div", { "data-id": "json-viewer-content", className: cn('flex min-h-0 w-full max-w-none flex-1 flex-col px-3 py-4 sm:px-6 sm:py-5', fillHeight && 'h-full'), children: [hasToolbar && (_jsxs("div", { "data-id": "json-viewer-toolbar", className: "relative flex min-h-8 shrink-0 flex-nowrap items-center gap-x-2", children: [_jsxs("div", { "data-id": "jsonView-toolbar-actions", className: "ml-auto flex h-8 shrink-0 items-center gap-1", children: [selectedTab !== 'source' && searchable && (searchOpen ? (_jsxs("label", { "data-id": "jsonView-search-control", className: "flex h-7 w-44 items-center overflow-hidden rounded-md border border-transparent bg-transparent transition-[width,background-color] focus-within:border-border focus-within:bg-card sm:w-52", children: [_jsx(Search, { className: "mx-1.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" }), _jsx("input", { ref: searchInput, "aria-label": "Search rows", className: "h-full min-w-0 flex-1 bg-transparent pr-2 text-xs outline-none placeholder:text-muted-foreground", value: query, onBlur: closeSearch, onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
296
- if (event.key === 'Escape') {
297
- setQuery('');
298
- setSearchOpenTab(undefined);
299
- }
300
- }, placeholder: "Type to search\u2026" })] })) : (_jsx("button", { "data-id": "jsonView-search-button", type: "button", "aria-label": "Search rows", title: "Search", className: "grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", onClick: () => setSearchOpenTab(selectedTab), children: _jsx(Search, { className: "h-3.5 w-3.5" }) }))), editing && settingsRawView && (_jsx(JsonViewViewSettings, { hasUnsavedChanges: previewRawView !== undefined, view: previewView ?? settingsView ?? activeView, rawView: activeView.display === 'html' ? activeRawView ?? settingsDraftRawView : settingsDraftRawView, schema: compiled.schema, disabled: editing.saving, saveMode: viewSaveMode, saveUnavailableReason: canPersistViews ? undefined : editing.viewCreationUnavailableReason, unavailableReason: settingsUnavailableReason, onPreview: previewSettings, onSave: saveSettingsView, onDuplicate: declaredView && activeRawView && canPersistViews ? async () => {
301
- const name = uniqueViewName(`${declaredView.name} copy`, rawViews);
302
- const id = uniqueViewId(name, rawViews);
303
- const duplicate = { ...activeRawView, id, name };
304
- const nextViews = [...rawViews];
305
- const duplicateIndex = declaredView.declarationIndex + 1;
306
- nextViews.splice(duplicateIndex, 0, duplicate);
307
- await persistViews(nextViews, duplicateIndex);
308
- setActiveTab(`view:${id}`);
309
- } : undefined, onDelete: declaredView && canPersistViews ? async () => {
310
- const nextViews = [...rawViews];
311
- nextViews.splice(declaredView.declarationIndex, 1);
312
- await persistViews(nextViews);
313
- setActiveTab('root');
314
- } : undefined }, typeof settingsDraftRawView.id === 'string' ? settingsDraftRawView.id : '__view_settings__')), sourceControl, _jsx(Diagnostics, { compiled: compiled })] }), (hasTabs || canAddViews || sourceContent) && (_jsxs("div", { "data-id": "jsonView-view-controls", className: "flex min-w-0 flex-1 items-center", children: [canAddViews && _jsx(JsonViewAddView, { align: "start", root: compiled.root, schema: compiled.schema, currentPath: jsonPathForValuePath(currentPath), disabled: editing.saving, onAdd: addView, unavailableReason: addViewUnavailableReason }, "add-view"), (hasTabs || sourceContent) && (_jsxs(TabsList, { "data-id": "jsonView-json-tabs", className: "h-8 min-w-0 flex-initial justify-start gap-0.5 overflow-x-auto overscroll-x-contain rounded-none bg-transparent p-0", children: [compiled.views.map((view) => {
315
- const Icon = view.display === 'html' ? LayoutGrid : view.display === 'kanban'
316
- ? Columns3
317
- : isInferredSingleRecord(view) || (view.value !== null && typeof view.value === 'object' && !Array.isArray(view.value) && !isRecordCollection(view.value))
318
- ? FileText
319
- : Table2;
320
- return _jsxs(ViewTab, { id: `jsonView-json-tab-${view.id}`, value: `view:${view.id}`, path: slashPath(view), children: [_jsx(Icon, { className: "h-3.5 w-3.5" }), view.name] }, view.id);
321
- }), _jsxs(ViewTab, { id: "jsonView-json-tab-root", value: "root", path: isInferredSingleRecord(rootView) && !forceTable ? 'Root record' : 'Root table', children: [isInferredSingleRecord(rootView) && !forceTable ? _jsx(FileText, { className: "h-3.5 w-3.5" }) : _jsx(Table2, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "sr-only", children: isInferredSingleRecord(rootView) && !forceTable ? 'Record' : 'Table' })] }), sourceContent && _jsxs(ViewTab, { id: "jsonView-json-tab-source", value: "source", path: "Source", children: [_jsx(Braces, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "sr-only", children: "Source" })] })] }, "view-tabs"))] }))] })), _jsx(TabsContent, { value: selectedTab, className: cn('mt-2 min-w-0', fillHeight && 'flex min-h-0 flex-1 flex-col'), children: selectedTab === 'source' ? sourceContent : _jsx(ConfiguredView, { forceTable: forceTable, compiled: compiled, copyFileName: defaultKey === 'root' ? 'document.json' : defaultKey, editing: editing, filePath: filePath, fillHeight: fillHeight, onCurrentPathChange: rememberCurrentPath, onSaveView: saveColumnView, query: query, rawView: columnRawView, renderExternalWidget: renderExternalWidget, renderMarkdown: renderMarkdown, uiSize: uiSize, view: renderedView }, selectedTab) })] }) }));
296
+ return (_jsx(TableCellOptionsContext.Provider, { value: tableCells, children: _jsx(Tabs, { value: selectedTab, onValueChange: (value) => { if (value !== 'source')
297
+ setActiveTab(value); onSourceVisibleChange?.(value === 'source'); }, className: cn('flex min-h-0 flex-1 flex-col bg-background text-foreground', fillHeight && 'h-full'), children: _jsxs("div", { "data-id": "json-viewer-content", className: cn('flex min-h-0 w-full max-w-none flex-1 flex-col px-3 py-4 sm:px-6 sm:py-5', fillHeight && 'h-full'), children: [hasToolbar && (_jsxs("div", { "data-id": "json-viewer-toolbar", className: "relative flex min-h-8 shrink-0 flex-nowrap items-center gap-x-2", children: [_jsxs("div", { "data-id": "jsonView-toolbar-actions", className: "ml-auto flex h-8 shrink-0 items-center gap-1", children: [selectedTab !== 'source' && searchable && (searchOpen ? (_jsxs("label", { "data-id": "jsonView-search-control", className: "flex h-7 w-44 items-center overflow-hidden rounded-md border border-transparent bg-transparent transition-[width,background-color] focus-within:border-border focus-within:bg-card sm:w-52", children: [_jsx(Search, { className: "mx-1.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" }), _jsx("input", { ref: searchInput, "aria-label": "Search rows", className: "h-full min-w-0 flex-1 bg-transparent pr-2 text-xs outline-none placeholder:text-muted-foreground", value: query, onBlur: closeSearch, onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
298
+ if (event.key === 'Escape') {
299
+ setQuery('');
300
+ setSearchOpenTab(undefined);
301
+ }
302
+ }, placeholder: "Type to search\u2026" })] })) : (_jsx("button", { "data-id": "jsonView-search-button", type: "button", "aria-label": "Search rows", title: "Search", className: "grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", onClick: () => setSearchOpenTab(selectedTab), children: _jsx(Search, { className: "h-3.5 w-3.5" }) }))), editing && settingsRawView && (_jsx(JsonViewViewSettings, { hasUnsavedChanges: previewRawView !== undefined, view: previewView ?? settingsView ?? activeView, rawView: activeView.display === 'html' ? activeRawView ?? settingsDraftRawView : settingsDraftRawView, schema: compiled.schema, disabled: editing.saving, saveMode: viewSaveMode, saveUnavailableReason: canPersistViews ? undefined : editing.viewCreationUnavailableReason, unavailableReason: settingsUnavailableReason, onPreview: previewSettings, onSave: saveSettingsView, onDuplicate: declaredView && activeRawView && canPersistViews ? async () => {
303
+ const name = uniqueViewName(`${declaredView.name} copy`, rawViews);
304
+ const id = uniqueViewId(name, rawViews);
305
+ const duplicate = { ...activeRawView, id, name };
306
+ const nextViews = [...rawViews];
307
+ const duplicateIndex = declaredView.declarationIndex + 1;
308
+ nextViews.splice(duplicateIndex, 0, duplicate);
309
+ await persistViews(nextViews, duplicateIndex);
310
+ setActiveTab(`view:${id}`);
311
+ } : undefined, onDelete: declaredView && canPersistViews ? async () => {
312
+ const nextViews = [...rawViews];
313
+ nextViews.splice(declaredView.declarationIndex, 1);
314
+ await persistViews(nextViews);
315
+ setActiveTab('root');
316
+ } : undefined }, typeof settingsDraftRawView.id === 'string' ? settingsDraftRawView.id : '__view_settings__')), sourceControl, _jsx(Diagnostics, { compiled: compiled })] }), (hasTabs || canAddViews || sourceContent) && (_jsxs("div", { "data-id": "jsonView-view-controls", className: "flex min-w-0 flex-1 items-center", children: [canAddViews && _jsx(JsonViewAddView, { align: "start", root: compiled.root, schema: compiled.schema, currentPath: jsonPathForValuePath(currentPath), disabled: editing.saving, onAdd: addView, unavailableReason: addViewUnavailableReason }, "add-view"), (hasTabs || sourceContent) && (_jsxs(TabsList, { "data-id": "jsonView-json-tabs", className: "h-8 min-w-0 flex-initial justify-start gap-0.5 overflow-x-auto overscroll-x-contain rounded-none bg-transparent p-0", children: [compiled.views.map((view) => {
317
+ const Icon = view.display === 'html' ? LayoutGrid : view.display === 'kanban'
318
+ ? Columns3
319
+ : isInferredSingleRecord(view) || (view.value !== null && typeof view.value === 'object' && !Array.isArray(view.value) && !isRecordCollection(view.value))
320
+ ? FileText
321
+ : Table2;
322
+ return _jsxs(ViewTab, { id: `jsonView-json-tab-${view.id}`, value: `view:${view.id}`, path: slashPath(view), children: [_jsx(Icon, { className: "h-3.5 w-3.5" }), view.name] }, view.id);
323
+ }), _jsxs(ViewTab, { id: "jsonView-json-tab-root", value: "root", path: isInferredSingleRecord(rootView) && !forceTable ? 'Root record' : 'Root table', children: [isInferredSingleRecord(rootView) && !forceTable ? _jsx(FileText, { className: "h-3.5 w-3.5" }) : _jsx(Table2, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "sr-only", children: isInferredSingleRecord(rootView) && !forceTable ? 'Record' : 'Table' })] }), sourceContent && _jsxs(ViewTab, { id: "jsonView-json-tab-source", value: "source", path: "Source", children: [_jsx(Braces, { className: "h-3.5 w-3.5" }), _jsx("span", { className: "sr-only", children: "Source" })] })] }, "view-tabs"))] }))] })), _jsx(TabsContent, { value: selectedTab, className: cn('mt-2 min-w-0', fillHeight && 'flex min-h-0 flex-1 flex-col'), children: selectedTab === 'source' ? sourceContent : _jsx(ConfiguredView, { forceTable: forceTable, compiled: compiled, copyFileName: defaultKey === 'root' ? 'document.json' : defaultKey, editing: editing, filePath: filePath, fillHeight: fillHeight, onCurrentPathChange: rememberCurrentPath, onSaveView: saveColumnView, query: query, rawView: columnRawView, renderExternalWidget: renderExternalWidget, renderMarkdown: renderMarkdown, uiSize: uiSize, view: renderedView }, selectedTab) })] }) }) }));
322
324
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useCallback, useContext, useEffect, useRef, useState } from 'react';
3
3
  import { AlertTriangle, Check, ExternalLink } from 'lucide-react';
4
4
  import { cn } from '../lib/cn.js';
5
+ import { submitsOnEnter, useEnterKeyBehavior } from '../lib/enter-key.js';
5
6
  import { OptionPill, optionColorsForValues } from '../primitives/option-pill.js';
6
7
  import { EditValueAction, StructuredValueCellFrame } from '../structured-data/atomic-value-editor.js';
7
8
  import { StructuredCellFillContext } from '../structured-data/structured-cell-fill-context.js';
@@ -106,6 +107,7 @@ export function SchemaEditor({ descriptor, disabled, label, minHeight, onCancel,
106
107
  const baseChanged = useEditBaseChanged();
107
108
  const fillCell = useContext(StructuredCellFillContext);
108
109
  const format = descriptor.type;
110
+ const enterKey = useEnterKeyBehavior(widgets.getDefinition(format)?.enterKey);
109
111
  const stringDraft = typeof draft === 'string' || typeof draft === 'number' || typeof draft === 'boolean'
110
112
  ? String(draft)
111
113
  : Array.isArray(draft) ? JSON.stringify(draft) : '';
@@ -208,10 +210,16 @@ export function SchemaEditor({ descriptor, disabled, label, minHeight, onCancel,
208
210
  : (format === 'body' || format === 'markdown' || format === 'html')
209
211
  ? 'leading-normal [&_textarea]:[font:inherit]'
210
212
  : '[&_input]:[font:inherit] [&_textarea]:[font:inherit]'), "aria-label": `Edit ${label}`, onKeyDown: (event) => {
211
- if (event.key !== 'Enter' || event.target instanceof HTMLButtonElement)
213
+ // React delivers keys from a widget's portaled popup (a date picker,
214
+ // a choice menu) here too; that popup owns them. A widget that
215
+ // handled Enter itself, such as a code editor, keeps it.
216
+ const target = event.target;
217
+ if (event.defaultPrevented || !(target instanceof Element) || !event.currentTarget.contains(target))
212
218
  return;
213
- const textarea = event.target instanceof HTMLTextAreaElement;
214
- if (textarea && !event.metaKey && !event.ctrlKey)
219
+ if (target instanceof HTMLButtonElement)
220
+ return;
221
+ const multiline = target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable);
222
+ if (!submitsOnEnter(event, { multiline, enterKey }))
215
223
  return;
216
224
  event.preventDefault();
217
225
  void commit();
@@ -3,7 +3,7 @@ import { isLongText } from './long-text.js';
3
3
  import { useJsonSourceLiterals } from './source-literals.js';
4
4
  import { useCallback, useEffect, useMemo, useState } from 'react';
5
5
  import { useJsonViewsRegistries } from '../widget-registry.js';
6
- import { AtomicValueEditor } from '../structured-data/atomic-value-editor.js';
6
+ import { AtomicValueEditor, EditValueAction, StructuredValueCellFrame } from '../structured-data/atomic-value-editor.js';
7
7
  import { isEditableAtomicValue } from '../structured-data/atomic-value.js';
8
8
  import { TabularObjectArrayView, ValueCell, ValueCellContent } from '../tabular-data-view.js';
9
9
  import {} from '@script-it/json-views-core';
@@ -46,8 +46,21 @@ export function ProjectedTableView({ compiled, editing, filePath, copyFileName,
46
46
  const [editPath, setEditPath] = useState();
47
47
  const [openRowPath, setOpenRowPath] = useState();
48
48
  const [openCell, setOpenCell] = useState();
49
- const unfilteredProjection = useMemo(() => projectJsonViewCollection(compiled.root, { ...view, filter: undefined }, compiled.schema, types, sourceLiteral), [compiled.root, compiled.schema, view, types, types.version, sourceLiteral]);
50
49
  const completeProjection = useMemo(() => projectJsonViewCollection(compiled.root, view, compiled.schema, types, sourceLiteral), [compiled.root, compiled.schema, view, types, types.version, sourceLiteral]);
50
+ // Without a filter, the complete projection already is the unfiltered one.
51
+ const unfilteredProjection = useMemo(() => view.filter
52
+ ? projectJsonViewCollection(compiled.root, { ...view, filter: undefined }, compiled.schema, types, sourceLiteral)
53
+ : completeProjection, [compiled.root, compiled.schema, completeProjection, view, types, types.version, sourceLiteral]);
54
+ // The first record with a schema match decides each column's descriptor. This
55
+ // does not depend on the search query, unlike the rest of the table model.
56
+ const columnDescriptors = useMemo(() => Object.fromEntries(completeProjection.columns.map((column) => {
57
+ for (const row of unfilteredProjection.rows) {
58
+ const schema = schemaForJsonViewPath(compiled.schema, resolveJsonViewRowPath(compiled.root, row, column.path).sourcePath);
59
+ if (schema)
60
+ return [column.id, schema.descriptor];
61
+ }
62
+ return [column.id, compiled.schema.find((schema) => sameJsonViewPath(schema.path, column.path))?.descriptor];
63
+ })), [compiled.root, compiled.schema, completeProjection.columns, unfilteredProjection]);
51
64
  const projected = useMemo(() => {
52
65
  const indices = completeProjection.rows.flatMap((row, index) => matchesSearch(row.value, query) ? [index] : []);
53
66
  return {
@@ -65,16 +78,8 @@ export function ProjectedTableView({ compiled, editing, filePath, copyFileName,
65
78
  fieldOccurrences.set(field, occurrence + 1);
66
79
  return [column.id, JSON.stringify([field, occurrence])];
67
80
  }));
68
- const descriptors = Object.fromEntries(visibleColumns.map((column) => {
69
- const descriptor = unfilteredProjection.rows.flatMap((row) => {
70
- const path = resolveJsonViewRowPath(compiled.root, row, column.path).sourcePath;
71
- const schema = schemaForJsonViewPath(compiled.schema, path);
72
- return schema ? [schema.descriptor] : [];
73
- })[0] ?? compiled.schema.find((schema) => sameJsonViewPath(schema.path, column.path))?.descriptor;
74
- return [column.id, descriptor];
75
- }));
76
81
  const columnFilterOperators = Object.fromEntries(visibleColumns.map((column) => {
77
- const descriptor = descriptors[column.id];
82
+ const descriptor = columnDescriptors[column.id];
78
83
  const defaults = descriptor?.type === 'date' ? DATE_FILTER_OPERATORS : FILTER_OPERATORS;
79
84
  const declared = descriptor ? types.filterOperators(descriptor) : undefined;
80
85
  return [column.id, declared ? [
@@ -94,7 +99,7 @@ export function ProjectedTableView({ compiled, editing, filePath, copyFileName,
94
99
  ])),
95
100
  titleColumn: visibleColumns[0],
96
101
  };
97
- }, [compiled.root, compiled.schema, completeProjection, projected.records, types, unfilteredProjection]);
102
+ }, [columnDescriptors, completeProjection, projected.records, types, unfilteredProjection]);
98
103
  const declaredColumnActions = rawView && onSaveView ? {
99
104
  activeFilters: view.filter?.rules.flatMap((rule) => {
100
105
  const column = tableModel.visibleColumns.find((candidate) => sameJsonViewPath(candidate.path, rule.path));
@@ -281,8 +286,16 @@ export function ProjectedTableView({ compiled, editing, filePath, copyFileName,
281
286
  const schema = schemaForJsonViewPath(compiled.schema, resolved.sourcePath);
282
287
  const descriptor = schema?.descriptor;
283
288
  const coloredDescriptor = descriptor && withColumnOptionColors(descriptor, columnFilterValues[column] ?? [value], visibleColumns.findIndex((candidate) => candidate.id === column));
284
- if (isLongText(value, descriptor))
285
- return (_jsx("button", { type: "button", className: "block h-full w-full min-w-0 truncate px-1.5 py-1 text-left hover:bg-accent focus-visible:ring-1 focus-visible:ring-ring", "aria-label": `Open ${displayColumn.label} in record`, onClick: () => { setEditPath(resolved.sourcePath); setOpenRowPath(row.sourcePath); }, children: value || '—' }));
289
+ if (isLongText(value, descriptor)) {
290
+ const openRecordEditor = () => { setEditPath(resolved.sourcePath); setOpenRowPath(row.sourcePath); };
291
+ // Render before applying the plain-text shortcut. A group preserves
292
+ // native link interactions without nesting links inside a button.
293
+ if (renderMarkdown && (descriptor?.type === 'markdown' || descriptor?.type === 'body'))
294
+ return (_jsx(StructuredValueCellFrame, { activationLabel: `Open ${displayColumn.label} in record`, activationRole: "group", onActivate: openRecordEditor, actions: editing && editing.canReplace?.(resolved.sourcePath) !== false
295
+ ? _jsx(EditValueAction, { label: displayColumn.label, onClick: openRecordEditor })
296
+ : undefined, children: _jsx("div", { className: "min-w-0 flex-1 break-words pr-2", children: _jsx(JsonViewSchemaValueDisplay, { sourcePath: resolved.sourcePath, descriptor: descriptor, value: value, onOpen: open, renderMarkdown: renderMarkdown }) }) }));
297
+ return _jsx("button", { type: "button", className: "block h-full w-full min-w-0 truncate px-1.5 py-1 text-left hover:bg-accent focus-visible:ring-1 focus-visible:ring-ring", "aria-label": `Open ${displayColumn.label} in record`, onClick: openRecordEditor, children: value || '—' });
298
+ }
286
299
  if (coloredDescriptor) {
287
300
  return (_jsx(JsonViewSchemaValueCell, { sourcePath: resolved.sourcePath, descriptor: coloredDescriptor, suppressRequiredValidation: editing?.isDraftRow?.(row.sourcePath) === true, onOptionColorChange: editing?.setOptionColor && schema ? (optionValue, color) => editing.setOptionColor(schema.declaration, optionValue, color) : undefined, inputKey: String(resolved.sourcePath[resolved.sourcePath.length - 1] ?? displayColumn.label), label: descriptor.title ?? displayColumn.label, value: value, onOpen: open, onClear: editing && resolved.value !== JSON_VIEW_PATH_MISSING ? () => editing.clear(resolved.sourcePath) : undefined, onCommit: editing && editing.canReplace?.(resolved.sourcePath) !== false && resolved.value !== JSON_VIEW_PATH_MISSING ? (next) => editing.replace(resolved.sourcePath, next) : undefined, renderExternalWidget: renderExternalWidget, renderMarkdown: renderMarkdown, saving: editing?.saving }));
288
301
  }
@@ -3,7 +3,7 @@ import { type CompiledJsonViewMetadata, type CompiledJsonViewView, type JsonView
3
3
  export declare function isRecordCollection(value: unknown): boolean;
4
4
  export declare function presentsRecordCollection(view: CompiledJsonViewView, forceTable?: boolean): boolean;
5
5
  export declare function matchesSearch(value: unknown, query: string): boolean;
6
- export declare function rawViewsFor(compiled: CompiledJsonViewMetadata): unknown[];
6
+ export declare function rawViewsFor(compiled: CompiledJsonViewMetadata): readonly unknown[];
7
7
  export declare function rawViewFor(compiled: CompiledJsonViewMetadata, view: CompiledJsonViewView): Record<string, unknown> | undefined;
8
8
  export declare function uniqueViewName(label: string, views: readonly unknown[]): string;
9
9
  export declare function uniqueViewId(name: string, views: readonly unknown[]): string;
@@ -34,6 +34,8 @@ export function matchesSearch(value, query) {
34
34
  return String(value).toLocaleLowerCase().includes(normalized);
35
35
  }
36
36
  }
37
+ // A stable empty result keeps memos that depend on the raw views from recomputing every render.
38
+ const NO_RAW_VIEWS = Object.freeze([]);
37
39
  export function rawViewsFor(compiled) {
38
40
  const metadata = compiled.metadata;
39
41
  if (metadata !== null && typeof metadata === 'object' && !Array.isArray(metadata)) {
@@ -41,7 +43,7 @@ export function rawViewsFor(compiled) {
41
43
  if (Array.isArray(views))
42
44
  return views;
43
45
  }
44
- return compiled.inference?.views ?? [];
46
+ return compiled.inference?.views ?? NO_RAW_VIEWS;
45
47
  }
46
48
  export function rawViewFor(compiled, view) {
47
49
  const raw = rawViewsFor(compiled)[view.declarationIndex];
@@ -516,28 +516,48 @@ function isRecordCollectionValue(value) {
516
516
  return value.every(isRecord);
517
517
  return isRecord(value) && Object.values(value).every(isRecord);
518
518
  }
519
- function discoverViewPaths(root) {
519
+ /** The option for one location, as a walk of the whole document would list it:
520
+ * own properties only, nothing under `$jsonviews`, and the canonical path
521
+ * spelling. Scalars are options too; they just are not record collections.
522
+ * Without a path, the root is the option.
523
+ */
524
+ function resolveViewPathOption(root, currentPath) {
520
525
  if (!isRecord(root) && !Array.isArray(root))
521
- return [];
522
- const options = [];
523
- const visit = (value, path) => {
524
- options.push({
525
- path: collectionPath(path),
526
- label: path.length === 0 ? (Array.isArray(root) ? 'Data' : 'Root') : path.map((segment) => typeof segment === 'number' ? String(segment + 1) : friendlyLabel(segment)).join(' › '),
527
- recordCollection: isRecordCollectionValue(value),
528
- });
529
- if (Array.isArray(value)) {
530
- value.forEach((child, index) => visit(child, [...path, index]));
531
- return;
526
+ return undefined;
527
+ const path = [];
528
+ let value = root;
529
+ if (currentPath !== undefined) {
530
+ let segments;
531
+ try {
532
+ segments = parseJsonViewPath(currentPath, { root: '$' }).segments;
532
533
  }
533
- if (!isRecord(value))
534
- return;
535
- Object.entries(value)
536
- .filter(([key]) => key !== '$jsonviews')
537
- .forEach(([key, child]) => visit(child, [...path, key]));
534
+ catch {
535
+ return undefined;
536
+ }
537
+ for (const segment of segments) {
538
+ if (segment.kind === 'property') {
539
+ if (segment.key === '$jsonviews' || !isRecord(value) || !Object.prototype.hasOwnProperty.call(value, segment.key))
540
+ return undefined;
541
+ value = value[segment.key];
542
+ path.push(segment.key);
543
+ }
544
+ else if (segment.kind === 'index') {
545
+ if (!Array.isArray(value) || !Object.prototype.hasOwnProperty.call(value, segment.index))
546
+ return undefined;
547
+ value = value[segment.index];
548
+ path.push(segment.index);
549
+ }
550
+ else
551
+ return undefined;
552
+ }
553
+ if (collectionPath(path) !== currentPath)
554
+ return undefined;
555
+ }
556
+ return {
557
+ path: collectionPath(path),
558
+ label: path.length === 0 ? (Array.isArray(root) ? 'Data' : 'Root') : path.map((segment) => typeof segment === 'number' ? String(segment + 1) : friendlyLabel(segment)).join(' › '),
559
+ recordCollection: isRecordCollectionValue(value),
538
560
  };
539
- visit(root, []);
540
- return options;
541
561
  }
542
562
  function collectionFields(root, option, schema) {
543
563
  if (!option)
@@ -577,12 +597,11 @@ export function JsonViewAddView({ align = 'start', currentPath, disabled = false
577
597
  const popoverRef = useRef(null);
578
598
  const portalContainer = useJsonViewsPortalContainer();
579
599
  const position = useAnchoredPosition(open, triggerRef, popoverRef, 'bottom', align);
580
- const options = useMemo(() => discoverViewPaths(root), [root]);
581
- const selectedOption = options.find((option) => option.path === currentPath)
582
- ?? (currentPath === undefined ? options[0] : undefined);
600
+ const selectedOption = useMemo(() => resolveViewPathOption(root, currentPath), [currentPath, root]);
583
601
  const unavailableReason = unavailableReasonOverride
584
602
  ?? (selectedOption ? undefined : 'This location is not document data that a view can target.');
585
- const fields = useMemo(() => collectionFields(root, selectedOption, schema), [root, schema, selectedOption]);
603
+ // Field discovery reads every record; only the open popover uses it.
604
+ const fields = useMemo(() => open ? collectionFields(root, selectedOption, schema) : [], [open, root, schema, selectedOption]);
586
605
  const groupFields = fields.filter((field) => field.kind !== 'array' && field.kind !== 'object');
587
606
  const preferredGroup = groupFields.find((field) => field.type === 'select') ?? groupFields[0];
588
607
  const reset = () => {
@@ -4,6 +4,7 @@ import { type ValuePath } from '@script-it/json-views-core';
4
4
  import { type CompiledJsonViewMetadata } from '@script-it/json-views-core';
5
5
  import { type JsonViewSchemaExternalWidgetRenderer } from './schema-value.js';
6
6
  import type { JsonValueReplacement } from '@script-it/json-views-core';
7
+ import type { JsonViewsTableCellOptions } from '../structured-data/table-cell-options.js';
7
8
  export interface JsonViewJsonEditing {
8
9
  canEditViews?: boolean;
9
10
  /** Why view creation is visible but unavailable. */
@@ -40,6 +41,8 @@ export interface JsonViewerProps {
40
41
  sourceVisible?: boolean;
41
42
  onSourceVisibleChange?: (visible: boolean) => void;
42
43
  sourceControl?: ReactNode;
44
+ /** Clamps tall table cells and reveals one on selection (default), or lets rows grow to fit. */
45
+ tableCells?: JsonViewsTableCellOptions;
43
46
  uiSize?: UiSize;
44
47
  viewsEnabled?: boolean;
45
48
  /** CSV is already tabular, irrespective of JSON prediction confidence. */
@@ -60,7 +60,11 @@ function NodeView({ node, navigateTo, filePath, currentPath, currentJsonPath, cu
60
60
  }
61
61
  if (typeof node === 'string') {
62
62
  const content = (_jsx(ExpandedStringValue, { value: node, renderAsMarkdown: renderAsMarkdown, renderMarkdown: renderMarkdown }));
63
- return atomicEditing ? (_jsx(AtomicValueEditor, { label: currentLabel, multiline: node.includes('\n'), onCommit: (nextValue) => atomicEditing.commit(currentJsonPath, nextValue), saving: atomicEditing.saving, value: node, children: content })) : content;
63
+ return atomicEditing ? (_jsx(AtomicValueEditor
64
+ // Shown as Markdown, the string is a document: Enter breaks the line.
65
+ , {
66
+ // Shown as Markdown, the string is a document: Enter breaks the line.
67
+ enterKey: renderAsMarkdown && renderMarkdown ? 'newline' : undefined, label: currentLabel, multiline: node.includes('\n'), onCommit: (nextValue) => atomicEditing.commit(currentJsonPath, nextValue), saving: atomicEditing.saving, value: node, children: content })) : content;
64
68
  }
65
69
  const content = (_jsx("div", { className: "whitespace-pre-wrap text-sm text-foreground", style: { overflowWrap: 'anywhere' }, children: sourceLiteral !== undefined ? _jsx(JsonSourceNumber, { literal: sourceLiteral }) : String(node) }));
66
70
  return atomicEditing && isEditableAtomicValue(node) ? (_jsx(AtomicValueEditor, { label: currentLabel, onCommit: (nextValue) => atomicEditing.commit(currentJsonPath, nextValue), saving: atomicEditing.saving, value: node, children: content })) : content;
@@ -210,6 +214,6 @@ function ObjectView({ obj, navigateTo, currentJsonPath, atomicEditing, renderMar
210
214
  if (!atomicEditing || atomicEditing.canReplace?.(jsonPath) === false) {
211
215
  return (_jsx(StructuredValueCellFrame, { actions: expandAction, children: content }));
212
216
  }
213
- return (_jsx(AtomicValueEditor, { actions: expandAction, label: k, multiline: true, onCommit: (nextValue) => atomicEditing.commit(jsonPath, nextValue), saving: atomicEditing.saving, value: v, children: content }));
217
+ return (_jsx(AtomicValueEditor, { actions: expandAction, enterKey: "newline", label: k, multiline: true, onCommit: (nextValue) => atomicEditing.commit(jsonPath, nextValue), saving: atomicEditing.saving, value: v, children: content }));
214
218
  })() : (_jsx(GeneralJsonValueCell, { atomicEditing: atomicEditing, label: k, navigateTo: () => navigateTo(k, [k]), path: [...currentJsonPath, k], renderMarkdown: renderMarkdown, renderExternalWidget: renderExternalWidget, schemaForPath: schemaForPath, value: v })) })] }, k))) }) }) }) }));
215
219
  }
@@ -0,0 +1,29 @@
1
+ /** What a plain Enter does in a multiline control: save the value, or break the line. */
2
+ export type EnterKeyBehavior = 'submit' | 'newline';
3
+ /** Structural, so React's synthetic events and native events both fit. */
4
+ export interface EnterKeyEvent {
5
+ key: string;
6
+ ctrlKey: boolean;
7
+ metaKey: boolean;
8
+ shiftKey: boolean;
9
+ isComposing?: boolean;
10
+ keyCode?: number;
11
+ nativeEvent?: {
12
+ isComposing?: boolean;
13
+ };
14
+ }
15
+ /** An IME is confirming composed text, so the Enter belongs to the IME.
16
+ * Safari reports that keydown with keyCode 229 instead of `isComposing`. */
17
+ export declare function isComposing(event: EnterKeyEvent): boolean;
18
+ /**
19
+ * Whether a keydown saves the editor it happened in. A single-line control
20
+ * saves on Enter. In a multiline control Cmd/Ctrl+Enter always saves,
21
+ * Shift+Enter never does (the browser inserts the line break), and a plain
22
+ * Enter saves only when the editor's behavior is `'submit'`.
23
+ */
24
+ export declare function submitsOnEnter(event: EnterKeyEvent, { multiline, enterKey }: {
25
+ multiline: boolean;
26
+ enterKey: EnterKeyBehavior;
27
+ }): boolean;
28
+ /** Soft keyboards have no Shift+Enter, so on mobile Return always breaks the line. */
29
+ export declare function useEnterKeyBehavior(preferred?: EnterKeyBehavior): EnterKeyBehavior;
@@ -0,0 +1,27 @@
1
+ import { useJsonViewsDevice } from '../browser-device.js';
2
+ /** An IME is confirming composed text, so the Enter belongs to the IME.
3
+ * Safari reports that keydown with keyCode 229 instead of `isComposing`. */
4
+ export function isComposing(event) {
5
+ return event.isComposing === true || event.nativeEvent?.isComposing === true || event.keyCode === 229;
6
+ }
7
+ /**
8
+ * Whether a keydown saves the editor it happened in. A single-line control
9
+ * saves on Enter. In a multiline control Cmd/Ctrl+Enter always saves,
10
+ * Shift+Enter never does (the browser inserts the line break), and a plain
11
+ * Enter saves only when the editor's behavior is `'submit'`.
12
+ */
13
+ export function submitsOnEnter(event, { multiline, enterKey }) {
14
+ if (event.key !== 'Enter' || isComposing(event))
15
+ return false;
16
+ if (!multiline)
17
+ return true;
18
+ if (event.metaKey || event.ctrlKey)
19
+ return true;
20
+ if (event.shiftKey)
21
+ return false;
22
+ return enterKey === 'submit';
23
+ }
24
+ /** Soft keyboards have no Shift+Enter, so on mobile Return always breaks the line. */
25
+ export function useEnterKeyBehavior(preferred = 'submit') {
26
+ return useJsonViewsDevice() === 'mobile' ? 'newline' : preferred;
27
+ }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import * as React from 'react';
3
3
  import { Check, ChevronDown, Palette } from 'lucide-react';
4
4
  import { cn } from '../lib/cn.js';
5
+ import { isComposing } from '../lib/enter-key.js';
5
6
  import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from './dropdown-menu.js';
6
7
  import { OptionPill, optionColorsForValues } from './option-pill.js';
7
8
  const OPTION_COLORS = [
@@ -59,7 +60,7 @@ export const ChoiceControl = React.forwardRef(({ mode, value, onValueChange, opt
59
60
  choose(nextValue);
60
61
  };
61
62
  const handleQueryKeyDown = (event) => {
62
- if (event.key === 'Enter' && canCreate) {
63
+ if (event.key === 'Enter' && canCreate && !isComposing(event)) {
63
64
  event.preventDefault();
64
65
  createOption();
65
66
  }
@@ -1,10 +1,13 @@
1
1
  import { type ReactNode } from 'react';
2
+ import { type EnterKeyBehavior } from '../lib/enter-key.js';
2
3
  import type { AtomicValue } from './atomic-value.js';
3
4
  interface AtomicValueEditorProps {
4
5
  actions?: ReactNode;
5
6
  children: ReactNode;
6
7
  className?: string;
7
8
  editorClassName?: string;
9
+ /** Long-form text keeps Enter for line breaks and saves on Cmd/Ctrl+Enter. */
10
+ enterKey?: EnterKeyBehavior;
8
11
  label: string;
9
12
  multiline?: boolean;
10
13
  onCommit: (value: AtomicValue) => Promise<void>;
@@ -17,7 +20,7 @@ export declare function EditValueAction({ disabled, label, onClick, }: {
17
20
  label: string;
18
21
  onClick: () => void;
19
22
  }): import("react").JSX.Element;
20
- export declare function AtomicValueEditor({ actions, children, className, editorClassName, label, multiline, onCommit, preserveLayout, saving, value, }: AtomicValueEditorProps): import("react").JSX.Element;
23
+ export declare function AtomicValueEditor({ actions, children, className, editorClassName, enterKey, label, multiline, onCommit, preserveLayout, saving, value, }: AtomicValueEditorProps): import("react").JSX.Element;
21
24
  export declare function StructuredValueCellFrame({ activationDisabled, activationLabel, activationRole, actions, children, leading, className, onActivate, overlayActions, }: {
22
25
  activationDisabled?: boolean;
23
26
  activationLabel?: string;
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useContext, useEffect, useLayoutEffect, useRef, useState, } from 'react';
3
3
  import { Pencil } from 'lucide-react';
4
4
  import { cn } from '../lib/cn.js';
5
+ import { submitsOnEnter, useEnterKeyBehavior } from '../lib/enter-key.js';
5
6
  import { StructuredCellFillContext } from './structured-cell-fill-context.js';
6
7
  import { useEditBaseChanged } from './edit-base.js';
7
8
  const CONTROL_CLASS = 'min-h-8 w-full min-w-0 rounded-md border border-border bg-background px-2 [font:inherit] text-foreground outline-none transition-colors placeholder:text-muted-foreground hover:border-input focus:border-ring focus:ring-2 focus:ring-inset focus:ring-ring/20';
@@ -11,7 +12,7 @@ export function EditValueAction({ disabled = false, label, onClick, }) {
11
12
  onClick();
12
13
  }, children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) }));
13
14
  }
14
- export function AtomicValueEditor({ actions, children, className, editorClassName, label, multiline = false, onCommit, preserveLayout = false, saving = false, value, }) {
15
+ export function AtomicValueEditor({ actions, children, className, editorClassName, enterKey, label, multiline = false, onCommit, preserveLayout = false, saving = false, value, }) {
15
16
  const [editing, setEditing] = useState(false);
16
17
  const fillCell = useContext(StructuredCellFillContext);
17
18
  const frameRef = useRef(null);
@@ -29,7 +30,7 @@ export function AtomicValueEditor({ actions, children, className, editorClassNam
29
30
  setEditing(false);
30
31
  };
31
32
  if (editing) {
32
- const editor = (_jsx(AtomicValueDirectEditor, { controlClassName: editorClassName, label: label, disabled: saving, minHeight: editorMinHeight, multiline: multiline, onCancel: stopEditing, onCommit: async (nextValue) => {
33
+ const editor = (_jsx(AtomicValueDirectEditor, { controlClassName: editorClassName, enterKey: enterKey, label: label, disabled: saving, minHeight: editorMinHeight, multiline: multiline, onCancel: stopEditing, onCommit: async (nextValue) => {
33
34
  await onCommit(nextValue);
34
35
  stopEditing();
35
36
  }, value: value }));
@@ -65,16 +66,21 @@ export function StructuredValueCellFrame({ activationDisabled = false, activatio
65
66
  // boundary of its column unmistakable.
66
67
  fillCell ? 'h-full items-center rounded-none py-2 pl-3 pr-0' : 'rounded-md px-1.5', onActivate && !activationDisabled
67
68
  ? fillCell
68
- ? 'before:absolute before:inset-y-0 before:-left-5 before:right-0 hover:before:bg-primary/10 dark:hover:before:bg-primary/25'
69
+ ? 'before:pointer-events-none before:absolute before:inset-y-0 before:-left-5 before:right-0 hover:before:bg-primary/10 dark:hover:before:bg-primary/25'
69
70
  : 'hover:bg-primary/10 dark:hover:bg-primary/25'
70
71
  : fillCell
71
- ? 'before:absolute before:inset-y-0 before:-left-5 before:right-0 hover:before:bg-accent/70'
72
+ ? 'before:pointer-events-none before:absolute before:inset-y-0 before:-left-5 before:right-0 hover:before:bg-accent/70'
72
73
  : 'hover:bg-accent/70', fillCell && onActivate && !activationDisabled && 'cursor-text', className), onClick: (event) => {
73
74
  // The table cell's padding belongs to the same action as its value.
74
75
  // Child actions keep their own handlers and must not activate twice.
75
76
  if (fillCell && event.target === event.currentTarget && !activationDisabled)
76
77
  onActivate?.();
77
- }, children: [leading && _jsx("span", { className: "mr-1.5 flex min-h-7 shrink-0 items-center", children: leading }), _jsx("div", { "data-id": onActivate ? 'atomic-edit-value' : undefined, "aria-disabled": onActivate ? activationDisabled : undefined, "aria-label": onActivate ? activationLabel : undefined, role: onActivate ? activationRole : undefined, tabIndex: onActivate && !activationDisabled ? 0 : undefined, title: onActivate ? activationLabel : undefined, className: cn('flex min-h-7 min-w-0 flex-1 items-center', fillCell && 'self-stretch',
78
+ }, children: [leading && _jsx("span", { className: "mr-1.5 flex min-h-7 shrink-0 items-center", children: leading }), _jsx("div", { "data-id": onActivate ? 'atomic-edit-value' : undefined, "aria-disabled": onActivate ? activationDisabled : undefined, "aria-label": onActivate ? activationLabel : undefined, role: onActivate ? activationRole : undefined, tabIndex: onActivate && !activationDisabled ? 0 : undefined, title: onActivate ? activationLabel : undefined, className: cn('flex min-h-7 min-w-0 flex-1 items-center',
79
+ // Auto margins centre a value like `items-center` does, but drop
80
+ // to zero when a clamped table cell is shorter than the value, so
81
+ // it starts at the top instead of losing its first lines above
82
+ // the clip.
83
+ fillCell && 'self-stretch *:my-auto',
78
84
  // The actions overlay the trailing edge, so the value ends before
79
85
  // their lane: truncation stops short of the chip rather than being
80
86
  // covered by it. Reserved unconditionally — sizing it on hover
@@ -113,7 +119,7 @@ export function StructuredValueCellFrame({ activationDisabled = false, activatio
113
119
  // chip belongs beside its first line.
114
120
  overlayActions ? 'top-2 right-2 rounded-md bg-background/90 shadow-sm' : fillCell ? 'top-1/2 -translate-y-1/2' : 'top-0.5'), children: actions }))] }));
115
121
  }
116
- function AtomicValueDirectEditor({ controlClassName, disabled = false, label, minHeight, multiline, onCancel, onCommit, value, }) {
122
+ function AtomicValueDirectEditor({ controlClassName, disabled = false, enterKey: preferredEnterKey, label, minHeight, multiline, onCancel, onCommit, value, }) {
117
123
  const [draft, setDraft] = useState(typeof value === 'boolean' ? value : String(value));
118
124
  const [error, setError] = useState();
119
125
  const containerRef = useRef(null);
@@ -122,6 +128,7 @@ function AtomicValueDirectEditor({ controlClassName, disabled = false, label, mi
122
128
  const originalValueRef = useRef(value);
123
129
  const attemptedCommitRef = useRef(false);
124
130
  const baseChanged = useEditBaseChanged();
131
+ const enterKey = useEnterKeyBehavior(preferredEnterKey);
125
132
  useEffect(() => {
126
133
  containerRef.current?.querySelector('input, textarea')?.focus({ preventScroll: multiline });
127
134
  }, [multiline]);
@@ -130,7 +137,9 @@ function AtomicValueDirectEditor({ controlClassName, disabled = false, label, mi
130
137
  return;
131
138
  const textarea = textareaRef.current;
132
139
  textarea.style.height = 'auto';
133
- textarea.style.height = `${Math.max(minHeight ?? 0, textarea.scrollHeight)}px`;
140
+ // A border-box height also holds the borders `scrollHeight` leaves out.
141
+ const borders = textarea.offsetHeight - textarea.clientHeight;
142
+ textarea.style.height = `${Math.max(minHeight ?? 0, textarea.scrollHeight + borders)}px`;
134
143
  }, [draft, minHeight, multiline]);
135
144
  const commit = async (nextBoolean) => {
136
145
  if (committingRef.current)
@@ -182,8 +191,7 @@ function AtomicValueDirectEditor({ controlClassName, disabled = false, label, mi
182
191
  onCancel();
183
192
  return;
184
193
  }
185
- const isTextarea = event.target instanceof HTMLTextAreaElement;
186
- if (event.key === 'Enter' && (!isTextarea || event.metaKey || event.ctrlKey)) {
194
+ if (submitsOnEnter(event, { multiline: event.target instanceof HTMLTextAreaElement, enterKey })) {
187
195
  event.preventDefault();
188
196
  void commit();
189
197
  }
@@ -0,0 +1,20 @@
1
+ /** How table cells treat content taller than the cell height limit. */
2
+ export interface JsonViewsTableCellOptions {
3
+ /** `'clamp'` clips each cell at the maximum height; `'grow'` lets rows fit
4
+ * their tallest value. @default 'clamp' */
5
+ overflow?: 'clamp' | 'grow';
6
+ /** Pixels, or any CSS length. Sets `--json-views-table-cell-max-height`
7
+ * on the table, overriding host CSS for this viewer. @default '9rem' */
8
+ maxHeight?: number | string;
9
+ /** How a clamped cell shows the rest of its content: `'select'` floats it
10
+ * over the rows below while the cell is selected; `'none'` keeps it
11
+ * clipped. @default 'select' */
12
+ reveal?: 'select' | 'none';
13
+ }
14
+ /** Provided by `JsonViewer`, so every table it renders shares one policy. */
15
+ export declare const TableCellOptionsContext: import("react").Context<JsonViewsTableCellOptions | undefined>;
16
+ export declare function resolveTableCellOptions(options: JsonViewsTableCellOptions | undefined): {
17
+ clamp: boolean;
18
+ maxHeight: string | undefined;
19
+ reveal: boolean;
20
+ };