@elytracms/studio 0.0.25 → 0.0.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 (58) hide show
  1. package/convex/cms.ts +49 -7
  2. package/convex/delivery.ts +14 -16
  3. package/convex/references.ts +38 -2
  4. package/convex/schema.ts +6 -1
  5. package/dist/components/assets/assets-store.js +41 -30
  6. package/dist/components/assets/assets-store.js.map +1 -1
  7. package/dist/components/auth-context.d.ts +4 -2
  8. package/dist/components/auth-context.js +19 -19
  9. package/dist/components/auth-context.js.map +1 -1
  10. package/dist/components/cms/cms-store.js +5 -4
  11. package/dist/components/cms/cms-store.js.map +1 -1
  12. package/dist/components/cms/collection-table.js +10 -3
  13. package/dist/components/cms/collection-table.js.map +1 -1
  14. package/dist/components/cms/detail-editor.js +36 -9
  15. package/dist/components/cms/detail-editor.js.map +1 -1
  16. package/dist/components/cms/field-control.d.ts +2 -1
  17. package/dist/components/cms/field-control.js +161 -73
  18. package/dist/components/cms/field-control.js.map +1 -1
  19. package/dist/components/cms/versions-pane.js +28 -16
  20. package/dist/components/cms/versions-pane.js.map +1 -1
  21. package/dist/components/project-session.d.ts +11 -0
  22. package/dist/components/project-session.js.map +1 -1
  23. package/dist/components/routes/routes-shell.js +22 -22
  24. package/dist/components/routes/routes-shell.js.map +1 -1
  25. package/dist/components/studio-context.js +5 -1
  26. package/dist/components/studio-context.js.map +1 -1
  27. package/dist/lib/cms/published-documents.d.ts +6 -6
  28. package/dist/lib/cms/published-documents.js +21 -58
  29. package/dist/lib/cms/published-documents.js.map +1 -1
  30. package/dist/lib/cms-views/view-model.d.ts +7 -3
  31. package/dist/lib/cms-views/view-model.js +11 -5
  32. package/dist/lib/cms-views/view-model.js.map +1 -1
  33. package/dist/lib/data/collection-content.js +7 -5
  34. package/dist/lib/data/collection-content.js.map +1 -1
  35. package/dist/lib/data/data-store.js +34 -24
  36. package/dist/lib/data/data-store.js.map +1 -1
  37. package/dist/lib/persistence/convex-adapter.js +22 -0
  38. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  39. package/dist/lib/persistence/index.js +6 -2
  40. package/dist/lib/persistence/index.js.map +1 -1
  41. package/dist/lib/query/index.d.ts +6 -0
  42. package/dist/lib/query/index.js +6 -0
  43. package/dist/lib/query/index.js.map +1 -0
  44. package/dist/lib/query/operations-query.d.ts +85 -0
  45. package/dist/lib/query/operations-query.js +124 -0
  46. package/dist/lib/query/operations-query.js.map +1 -0
  47. package/dist/lib/references/reference-model.js +7 -1
  48. package/dist/lib/references/reference-model.js.map +1 -1
  49. package/dist/lib/references/references-store.d.ts +5 -10
  50. package/dist/lib/references/references-store.js +55 -77
  51. package/dist/lib/references/references-store.js.map +1 -1
  52. package/dist/ui/collapsible-section.d.ts +20 -0
  53. package/dist/ui/collapsible-section.js +18 -0
  54. package/dist/ui/collapsible-section.js.map +1 -0
  55. package/dist/ui/index.d.ts +1 -0
  56. package/dist/ui/index.js +1 -0
  57. package/dist/ui/index.js.map +1 -1
  58. package/package.json +7 -2
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlertTriangle, ChevronDown, ChevronUp, GripVertical, ImagePlus, Link2, Pencil, Plus, RotateCcw, SlidersHorizontal, Trash2, Upload, X } from 'lucide-react';
3
- import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { AlertTriangle, ChevronDown, ChevronUp, GripVertical, ImagePlus, Link2, MoreHorizontal, Pencil, Plus, RotateCcw, SlidersHorizontal, Trash2, Upload, X } from 'lucide-react';
3
+ import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
5
5
  import { BUILTIN_FIELD_CONTROLS, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, isFieldHidden, parentRelationField, readAssetId, } from '@elytracms/core/cms-core';
6
- import { Badge, Button, Card, Code, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Field, Input, Row, Select, Spacer, Stack, Text, } from '@elytracms/studio/ui';
6
+ import { Badge, Button, Card, Code, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Field, Input, Row, Select, Spacer, Stack, Text, } from '@elytracms/studio/ui';
7
7
  import { cn } from '@elytracms/studio/lib/utils';
8
8
  import { applyAssetSelection, assetIdsOf, singleAssetIdOf } from '../assets/asset-selection';
9
9
  import { useOptionalAssets } from '../assets/assets-store';
@@ -148,45 +148,36 @@ export function objectItemSummary(fields, item, index) {
148
148
  function truncateSummary(s, max = 60) {
149
149
  return s.length > max ? `${s.slice(0, max - 1)}…` : s;
150
150
  }
151
- /**
152
- * The nested subfield form for one object item (EC-253). Reuses {@link FieldControl}
153
- * recursively, so any subfield type — including a nested object/repeater — works for
154
- * free. Writing a subfield merges it back into the item and bubbles the new item up.
155
- */
156
- function ObjectItemForm({ fields, value, onChange, readOnly, idPrefix, }) {
157
- return (_jsx(Stack, { gap: "3", className: "mt-2", children: fields.map((sub) => {
158
- // EC-273: a conditionally-hidden subfield (its predicate keys off the item's
159
- // own sibling values) is omitted — e.g. a discriminated link's `href` shows
160
- // only when `linkType === 'href'`. Re-evaluated as the item is edited.
161
- if (isFieldHidden(sub, { values: value }))
162
- return null;
163
- const subId = `${idPrefix}-${sub.name}`;
164
- return (_jsx(Field, { htmlFor: subId, hint: sub.form?.description, label: _jsxs(Row, { gap: "2", children: [_jsx("span", { children: fieldLabel(sub) }), sub.validation?.required ? _jsx("span", { className: "text-destructive", children: "*" }) : null] }), children: _jsx(FieldControl, { id: subId, field: sub, value: value[sub.name], readOnly: readOnly, onChange: (next) => {
165
- const updated = { ...value };
166
- if (next === undefined)
167
- delete updated[sub.name];
168
- else
169
- updated[sub.name] = next;
170
- onChange(updated);
171
- } }) }, sub.name));
172
- }) }));
151
+ // EC-305: nested-array depth, propagated via context so each array level can encode
152
+ // its depth (rail colour + tint) without prop-drilling through FieldControl's many
153
+ // branches. The detail editor renders top-level fields with the default depth 0.
154
+ const ArrayDepthContext = createContext(0);
155
+ const useArrayDepth = () => useContext(ArrayDepthContext);
156
+ // (B) Depth-keyed visuals: a coloured left rail + subtle tint so three levels deep
157
+ // no longer read as four identical borders. Literal classes so Tailwind emits them.
158
+ const DEPTH_RAIL = ['border-l-border', 'border-l-primary/60', 'border-l-info/60', 'border-l-warning/60'];
159
+ const DEPTH_TINT = ['', 'bg-muted/20', 'bg-muted/30', 'bg-muted/40'];
160
+ const depthRail = (d) => DEPTH_RAIL[Math.min(d, DEPTH_RAIL.length - 1)];
161
+ const depthTint = (d) => DEPTH_TINT[Math.min(d, DEPTH_TINT.length - 1)];
162
+ // (C) An object array renders as a compact TABLE when every subfield is a scalar (a
163
+ // flat row) and there are few enough columns to read; richer/nested object types stay
164
+ // as cards with depth encoding (B). Conditional (function-hidden) subfields force the
165
+ // card path — a table has no place to show/hide a column per row.
166
+ const TABULAR_FIELD_TYPES = new Set(['text', 'number', 'boolean', 'select', 'date']);
167
+ const MAX_TABLE_COLUMNS = 4;
168
+ export function isTabularObjectType(field) {
169
+ return (field.fields.length > 0 &&
170
+ field.fields.length <= MAX_TABLE_COLUMNS &&
171
+ field.fields.every((f) => TABULAR_FIELD_TYPES.has(f.type) && typeof f.form?.hidden !== 'function'));
173
172
  }
174
173
  /**
175
- * Object / repeater field control (EC-253). `cardinality: 'one'` renders a single
176
- * nested form in a card; `'many'` renders an add/remove/reorder list of item cards.
177
- * Editing happens entirely inside the shared field form — no canvas escape (unlike
178
- * `blocks`), so props and document fields share this control.
174
+ * Shared array state for an object repeater (EC-253/EC-294/EC-305): the value-only
175
+ * items + a parallel stable-key list kept in lockstep through every structural op
176
+ * (the EC-253 C2 key↔value lock), add/remove/move, and native drag-reorder. Used by
177
+ * both the card list (B) and the table (C) so they behave identically.
179
178
  */
180
- function ObjectField({ field, value, onChange, readOnly, id, }) {
181
- if (field.cardinality !== 'many') {
182
- return (_jsx(Card, { className: "border-l-2 border-l-border p-3", children: _jsx(ObjectItemForm, { fields: field.fields, value: asRecord(value), readOnly: readOnly, idPrefix: id, onChange: onChange }) }));
183
- }
179
+ function useObjectArray(field, value, onChange) {
184
180
  const items = Array.isArray(value) ? value : [];
185
- // Stable React keys for the value-only items (they carry no intrinsic id). Index
186
- // keys would mis-associate a row's child/DOM state (an open relation/asset picker,
187
- // focus) when rows are reordered or removed; we keep a parallel id list in lockstep
188
- // with structural ops, reconciled by length when the array is replaced wholesale
189
- // (e.g. switching documents). Edits keep the keys — only structure changes them.
190
181
  const seq = useRef(0);
191
182
  const keysRef = useRef([]);
192
183
  if (keysRef.current.length !== items.length) {
@@ -197,8 +188,6 @@ function ObjectField({ field, value, onChange, readOnly, id, }) {
197
188
  keysRef.current = nextKeys;
198
189
  onChange(nextItems);
199
190
  };
200
- // The same (from,to) drives both the value array AND the parallel key array, so
201
- // they stay aligned through a reorder (EC-253 C2 / EC-294).
202
191
  const move = (from, to) => commit(reorder(items, from, to), reorder(keys, from, to));
203
192
  const removeAt = (i) => commit(items.filter((_, j) => j !== i), keys.filter((_, j) => j !== i));
204
193
  const addItem = () => {
@@ -206,25 +195,15 @@ function ObjectField({ field, value, onChange, readOnly, id, }) {
206
195
  for (const sub of field.fields)
207
196
  if (sub.default !== undefined)
208
197
  seed[sub.name] = sub.default;
209
- commit([...items, seed], [...keys, seq.current++]);
198
+ const key = seq.current++;
199
+ commit([...items, seed], [...keys, key]);
200
+ return key;
210
201
  };
211
202
  const setItem = (i, updated) => {
212
203
  const nextItems = items.slice();
213
204
  nextItems[i] = updated;
214
205
  commit(nextItems, keys);
215
206
  };
216
- // EC-294: per-item collapse (ephemeral disclosure state keyed by the stable key —
217
- // not persisted) + native drag-to-reorder. ▲/▼ stay as the keyboard/a11y fallback.
218
- const [collapsedKeys, setCollapsedKeys] = useState(() => new Set());
219
- const toggleCollapsed = (key) => setCollapsedKeys((prev) => {
220
- const next = new Set(prev);
221
- if (next.has(key))
222
- next.delete(key);
223
- else
224
- next.add(key);
225
- return next;
226
- });
227
- const allCollapsed = items.length > 0 && keys.every((k) => collapsedKeys.has(k));
228
207
  const [dragIndex, setDragIndex] = useState(null);
229
208
  const [overIndex, setOverIndex] = useState(null);
230
209
  const endDrag = () => {
@@ -236,26 +215,135 @@ function ObjectField({ field, value, onChange, readOnly, id, }) {
236
215
  move(dragIndex, to);
237
216
  endDrag();
238
217
  };
239
- return (_jsxs(Stack, { gap: "2", children: [items.length > 1 && !readOnly ? (_jsx(Row, { justify: "end", children: _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setCollapsedKeys(allCollapsed ? new Set() : new Set(keys)), children: allCollapsed ? 'Expand all' : 'Collapse all' }) })) : null, items.length === 0 ? (_jsx(Text, { size: "sm", tone: "subtle", children: "No items" })) : (items.map((item, i) => {
240
- // keysRef is reconciled to items.length above, so keys[i] is always present;
241
- // `?? i` only satisfies noUncheckedIndexedAccess (never taken at runtime).
242
- const key = keys[i] ?? i;
243
- const record = asRecord(item);
244
- const isCollapsed = collapsedKeys.has(key);
245
- return (_jsxs(Card, { onDragOver: (e) => {
246
- if (dragIndex === null)
247
- return;
248
- e.preventDefault();
249
- if (overIndex !== i)
250
- setOverIndex(i);
251
- }, onDrop: () => drop(i), className: cn('border-l-2 border-l-border p-0', dragIndex === i ? 'opacity-50' : '', overIndex === i && dragIndex !== null && dragIndex !== i
252
- ? 'border-l-primary ring-1 ring-primary/40'
253
- : ''), children: [_jsxs(Row, { className: "select-none border-b border-border/60 bg-muted/30 px-2 py-1.5", children: [!readOnly ? (_jsx("span", { draggable: true, onDragStart: (e) => {
254
- e.dataTransfer.effectAllowed = 'move';
255
- e.dataTransfer.setData('text/plain', String(i));
256
- setDragIndex(i);
257
- }, onDragEnd: endDrag, "aria-label": "Drag to reorder", className: "cursor-grab text-muted-foreground/50 hover:text-muted-foreground active:cursor-grabbing", children: _jsx(GripVertical, { className: "size-3.5" }) })) : null, _jsxs("button", { type: "button", onClick: () => toggleCollapsed(key), "aria-expanded": !isCollapsed, className: "flex min-w-0 flex-1 items-center gap-1.5 text-left", children: [_jsx(ChevronDown, { className: cn('size-3.5 shrink-0 text-muted-foreground transition-transform', isCollapsed ? '-rotate-90' : ''), "aria-hidden": true }), _jsx(Text, { size: "sm", className: "min-w-0 truncate font-medium", children: objectItemSummary(field.fields, record, i) })] }), !readOnly ? (_jsxs(Row, { gap: "1", children: [_jsx(Button, { variant: "ghost", size: "sm", "aria-label": "Move up", disabled: i === 0, onClick: () => move(i, i - 1), children: _jsx(ChevronUp, { className: "size-3" }) }), _jsx(Button, { variant: "ghost", size: "sm", "aria-label": "Move down", disabled: i === items.length - 1, onClick: () => move(i, i + 1), children: _jsx(ChevronDown, { className: "size-3" }) }), _jsx(Button, { variant: "ghost", size: "sm", "aria-label": "Remove item", onClick: () => removeAt(i), children: _jsx(Trash2, { className: "size-3" }) })] })) : null] }), !isCollapsed ? (_jsx("div", { className: "px-3 pb-3", children: _jsx(ObjectItemForm, { fields: field.fields, value: record, readOnly: readOnly, idPrefix: `${id}-${i}`, onChange: (updated) => setItem(i, updated) }) })) : null] }, key));
258
- })), !readOnly ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: addItem, children: [_jsx(Plus, { className: "size-3" }), " Add item"] })) : null] }));
218
+ return { items, keys, move, removeAt, addItem, setItem, dragIndex, overIndex, setDragIndex, setOverIndex, endDrag, drop };
219
+ }
220
+ /**
221
+ * (E) Chrome reduction: the per-row move/remove controls collapse into one hover/
222
+ * focus overflow menu, so a row reads as content, not a six-button toolbar. Drag is
223
+ * the primary reorder; the arrows stay here for keyboard/a11y.
224
+ */
225
+ function ItemOverflowMenu({ index, count, onMove, onRemove, }) {
226
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "sm", "aria-label": "Item actions", children: _jsx(MoreHorizontal, { className: "size-3.5" }) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsxs(DropdownMenuItem, { disabled: index === 0, onClick: () => onMove(index, index - 1), children: [_jsx(ChevronUp, {}), " Move up"] }), _jsxs(DropdownMenuItem, { disabled: index === count - 1, onClick: () => onMove(index, index + 1), children: [_jsx(ChevronDown, {}), " Move down"] }), _jsxs(DropdownMenuItem, { onClick: onRemove, children: [_jsx(Trash2, {}), " Remove"] })] })] }));
227
+ }
228
+ /**
229
+ * The nested subfield form for one object item (EC-253). Reuses {@link FieldControl}
230
+ * recursively, so any subfield type — including a nested object/repeater — works for
231
+ * free. Writing a subfield merges it back into the item and bubbles the new item up.
232
+ */
233
+ function ObjectItemForm({ fields, value, onChange, readOnly, idPrefix, }) {
234
+ return (_jsx(Stack, { gap: "3", className: "mt-2", children: fields.map((sub) => {
235
+ // EC-273: a conditionally-hidden subfield (its predicate keys off the item's
236
+ // own sibling values) is omitted — e.g. a discriminated link's `href` shows
237
+ // only when `linkType === 'href'`. Re-evaluated as the item is edited.
238
+ if (isFieldHidden(sub, { values: value }))
239
+ return null;
240
+ const subId = `${idPrefix}-${sub.name}`;
241
+ return (_jsx(Field, { htmlFor: subId, hint: sub.form?.description, label: _jsxs(Row, { gap: "2", children: [_jsx("span", { children: fieldLabel(sub) }), sub.validation?.required ? _jsx("span", { className: "text-destructive", children: "*" }) : null] }), children: _jsx(FieldControl, { id: subId, field: sub, value: value[sub.name], readOnly: readOnly, onChange: (next) => {
242
+ const updated = { ...value };
243
+ if (next === undefined)
244
+ delete updated[sub.name];
245
+ else
246
+ updated[sub.name] = next;
247
+ onChange(updated);
248
+ } }) }, sub.name));
249
+ }) }));
250
+ }
251
+ /**
252
+ * Object / repeater field control (EC-253), legibility-rebuilt (EC-305). A single
253
+ * object (`cardinality: 'one'`) renders a depth-encoded card; an array (`'many'`)
254
+ * dispatches by shape: a **flat** object type (all scalar subfields) → a compact
255
+ * editable TABLE (C); a richer/nested type → a card list with depth encoding (B),
256
+ * summary-first collapse (D), and reduced per-row chrome (E). Editing stays in the
257
+ * shared field form — no canvas escape (unlike `blocks`) — so props and document
258
+ * fields share this control. Depth flows via {@link ArrayDepthContext}.
259
+ */
260
+ function ObjectField({ field, value, onChange, readOnly, id, }) {
261
+ const depth = useArrayDepth();
262
+ if (field.cardinality !== 'many') {
263
+ return (_jsx(Card, { className: cn('border-l-2 p-3', depthRail(depth), depthTint(depth)), children: _jsx(ArrayDepthContext.Provider, { value: depth + 1, children: _jsx(ObjectItemForm, { fields: field.fields, value: asRecord(value), readOnly: readOnly, idPrefix: id, onChange: onChange }) }) }));
264
+ }
265
+ const props = { field, value, onChange, readOnly, id, depth };
266
+ return isTabularObjectType(field) ? _jsx(ObjectTableField, { ...props }) : _jsx(ObjectCardList, { ...props });
267
+ }
268
+ /**
269
+ * (B + D + E) The card list for a nested/rich object array: depth-encoded cards
270
+ * (coloured rail + tint), items collapsed by default to a scannable summary outline
271
+ * (D), accordion expansion (open one → collapse siblings), and the per-row controls
272
+ * collapsed into one overflow menu (E). Drag-to-reorder; arrows live in the menu for
273
+ * a11y. Nested arrays render one depth deeper via the context provider.
274
+ */
275
+ function ObjectCardList({ field, value, onChange, readOnly, id, depth }) {
276
+ const arr = useObjectArray(field, value, onChange);
277
+ const { items, keys } = arr;
278
+ // (D) Default COLLAPSED: track which keys are EXPANDED (empty = all collapsed).
279
+ // (B) Accordion: expanding one collapses the siblings.
280
+ const [expandedKeys, setExpandedKeys] = useState(() => new Set());
281
+ const toggle = (key) => setExpandedKeys((prev) => {
282
+ if (prev.has(key)) {
283
+ const next = new Set(prev);
284
+ next.delete(key);
285
+ return next;
286
+ }
287
+ return new Set([key]);
288
+ });
289
+ const allExpanded = items.length > 0 && keys.every((k) => expandedKeys.has(k));
290
+ const onAdd = () => setExpandedKeys(new Set([arr.addItem()]));
291
+ return (_jsx(ArrayDepthContext.Provider, { value: depth + 1, children: _jsxs(Stack, { gap: "2", children: [items.length > 1 && !readOnly ? (_jsx(Row, { justify: "end", children: _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setExpandedKeys(allExpanded ? new Set() : new Set(keys)), children: allExpanded ? 'Collapse all' : 'Expand all' }) })) : null, items.length === 0 ? (_jsx(Text, { size: "sm", tone: "subtle", children: "No items" })) : (items.map((item, i) => {
292
+ // keysRef is reconciled to items.length, so keys[i] is always present;
293
+ // `?? i` only satisfies noUncheckedIndexedAccess (never taken at runtime).
294
+ const key = keys[i] ?? i;
295
+ const record = asRecord(item);
296
+ const isExpanded = expandedKeys.has(key);
297
+ return (_jsxs(Card, { onDragOver: (e) => {
298
+ if (arr.dragIndex === null)
299
+ return;
300
+ e.preventDefault();
301
+ if (arr.overIndex !== i)
302
+ arr.setOverIndex(i);
303
+ }, onDrop: () => arr.drop(i), className: cn('border-l-2 p-0', depthRail(depth), depthTint(depth), arr.dragIndex === i ? 'opacity-50' : '', arr.overIndex === i && arr.dragIndex !== null && arr.dragIndex !== i
304
+ ? 'border-l-primary ring-1 ring-primary/40'
305
+ : ''), children: [_jsxs(Row, { className: "select-none border-b border-border/60 bg-muted/20 px-2 py-1.5", children: [!readOnly ? (_jsx("span", { draggable: true, onDragStart: (e) => {
306
+ e.dataTransfer.effectAllowed = 'move';
307
+ e.dataTransfer.setData('text/plain', String(i));
308
+ arr.setDragIndex(i);
309
+ }, onDragEnd: arr.endDrag, "aria-label": "Drag to reorder", className: "cursor-grab text-muted-foreground/50 hover:text-muted-foreground active:cursor-grabbing", children: _jsx(GripVertical, { className: "size-3.5" }) })) : null, _jsxs("button", { type: "button", onClick: () => toggle(key), "aria-expanded": isExpanded, className: "flex min-w-0 flex-1 items-center gap-1.5 text-left", children: [_jsx(ChevronDown, { className: cn('size-3.5 shrink-0 text-muted-foreground transition-transform', isExpanded ? '' : '-rotate-90'), "aria-hidden": true }), _jsx(Text, { size: "sm", className: "min-w-0 truncate font-medium", children: objectItemSummary(field.fields, record, i) })] }), !readOnly ? (_jsx(ItemOverflowMenu, { index: i, count: items.length, onMove: arr.move, onRemove: () => arr.removeAt(i) })) : null] }), isExpanded ? (_jsx("div", { className: "px-3 pb-3", children: _jsx(ObjectItemForm, { fields: field.fields, value: record, readOnly: readOnly, idPrefix: `${id}-${i}`, onChange: (updated) => arr.setItem(i, updated) }) })) : null] }, key));
310
+ })), !readOnly ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: onAdd, children: [_jsx(Plus, { className: "size-3" }), " Add item"] })) : null] }) }));
311
+ }
312
+ /**
313
+ * (C) A compact editable table for a FLAT object array — columns are the scalar
314
+ * subfields, one row per item. Kills the card-stack sprawl for the commonest case
315
+ * (option lists, opening hours, social links). Drag-reorder + an overflow menu per
316
+ * row; cells reuse {@link FieldControl} so each subfield keeps its native control.
317
+ */
318
+ function ObjectTableField({ field, value, onChange, readOnly, id, depth }) {
319
+ const arr = useObjectArray(field, value, onChange);
320
+ const { items, keys } = arr;
321
+ const cols = field.fields;
322
+ const span = cols.length + (readOnly ? 0 : 2);
323
+ return (_jsxs("div", { className: cn('overflow-hidden rounded-md border border-border border-l-2', depthRail(depth), depthTint(depth)), children: [_jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border/60 bg-muted/30 text-left", children: [!readOnly ? _jsx("th", { className: "w-7" }) : null, cols.map((c) => (_jsxs("th", { className: "px-2 py-1.5 text-xs font-medium text-muted-foreground", children: [fieldLabel(c), c.validation?.required ? _jsx("span", { className: "text-destructive", children: " *" }) : null] }, c.name))), !readOnly ? _jsx("th", { className: "w-9" }) : null] }) }), _jsx("tbody", { children: items.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: span, className: "px-2 py-2 text-muted-foreground", children: _jsx(Text, { size: "sm", tone: "subtle", children: "No items" }) }) })) : (items.map((item, i) => {
324
+ const key = keys[i] ?? i;
325
+ const record = asRecord(item);
326
+ return (_jsxs("tr", { onDragOver: (e) => {
327
+ if (arr.dragIndex === null)
328
+ return;
329
+ e.preventDefault();
330
+ if (arr.overIndex !== i)
331
+ arr.setOverIndex(i);
332
+ }, onDrop: () => arr.drop(i), className: cn('border-b border-border/40 align-top last:border-0', arr.dragIndex === i ? 'opacity-50' : '', arr.overIndex === i && arr.dragIndex !== null && arr.dragIndex !== i
333
+ ? 'bg-primary/5'
334
+ : ''), children: [!readOnly ? (_jsx("td", { className: "px-1 py-1", children: _jsx("span", { draggable: true, onDragStart: (e) => {
335
+ e.dataTransfer.effectAllowed = 'move';
336
+ e.dataTransfer.setData('text/plain', String(i));
337
+ arr.setDragIndex(i);
338
+ }, onDragEnd: arr.endDrag, "aria-label": "Drag to reorder", className: "inline-flex cursor-grab text-muted-foreground/50 hover:text-muted-foreground active:cursor-grabbing", children: _jsx(GripVertical, { className: "size-3.5" }) }) })) : null, cols.map((c) => (_jsx("td", { className: "px-2 py-1", children: _jsx(FieldControl, { id: `${id}-${i}-${c.name}`, field: c, value: record[c.name], readOnly: readOnly, onChange: (next) => {
339
+ const updated = { ...record };
340
+ if (next === undefined)
341
+ delete updated[c.name];
342
+ else
343
+ updated[c.name] = next;
344
+ arr.setItem(i, updated);
345
+ } }) }, c.name))), !readOnly ? (_jsx("td", { className: "px-1 py-1", children: _jsx(ItemOverflowMenu, { index: i, count: items.length, onMove: arr.move, onRemove: () => arr.removeAt(i) }) })) : null] }, key));
346
+ })) })] }), !readOnly ? (_jsx("div", { className: "border-t border-border/60 p-1.5", children: _jsxs(Button, { variant: "ghost", size: "sm", onClick: () => arr.addItem(), children: [_jsx(Plus, { className: "size-3" }), " Add"] }) })) : null] }));
259
347
  }
260
348
  function RelationField({ field, value, onChange, readOnly, }) {
261
349
  const cms = useCms();