smoodly 0.0.15 → 0.0.17

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 (154) hide show
  1. package/README.md +238 -36
  2. package/dist/admin/auth.d.ts +7 -4
  3. package/dist/admin/auth.js +1 -1
  4. package/dist/admin/client-ops.js +3 -3
  5. package/dist/admin/custom-section.d.ts +10 -0
  6. package/dist/admin/custom-section.js +10 -0
  7. package/dist/admin/editor/CanvasOverlay.d.ts +6 -2
  8. package/dist/admin/editor/CanvasOverlay.js +6 -6
  9. package/dist/admin/editor/EditorView.d.ts +2 -0
  10. package/dist/admin/editor/EditorView.js +322 -62
  11. package/dist/admin/editor/GlobalInspector.d.ts +24 -0
  12. package/dist/admin/editor/GlobalInspector.js +13 -0
  13. package/dist/admin/editor/InsertPicker.d.ts +6 -5
  14. package/dist/admin/editor/InsertPicker.js +6 -4
  15. package/dist/admin/editor/InspectorFields.d.ts +21 -0
  16. package/dist/admin/editor/InspectorFields.js +22 -0
  17. package/dist/admin/editor/Outline.d.ts +0 -1
  18. package/dist/admin/editor/Outline.js +3 -3
  19. package/dist/admin/editor/PageSettings.js +3 -2
  20. package/dist/admin/editor/global-edit.d.ts +39 -0
  21. package/dist/admin/editor/global-edit.js +124 -0
  22. package/dist/admin/editor/useGlobalEdit.d.ts +6 -0
  23. package/dist/admin/editor/useGlobalEdit.js +18 -0
  24. package/dist/admin/forms/FieldWidget.d.ts +14 -1
  25. package/dist/admin/forms/FieldWidget.js +38 -11
  26. package/dist/admin/forms/ListField.d.ts +9 -1
  27. package/dist/admin/forms/ListField.js +16 -8
  28. package/dist/admin/forms/RichtextElementCard.js +11 -5
  29. package/dist/admin/forms/RowsWidget.d.ts +13 -0
  30. package/dist/admin/forms/RowsWidget.js +26 -0
  31. package/dist/admin/forms/SpanBar.d.ts +5 -0
  32. package/dist/admin/forms/SpanBar.js +37 -0
  33. package/dist/admin/forms/richtext-insert.d.ts +2 -2
  34. package/dist/admin/forms/richtext-insert.js +3 -3
  35. package/dist/admin/forms/richtext-nodes.js +1 -1
  36. package/dist/admin/forms/tabs.d.ts +20 -8
  37. package/dist/admin/forms/tabs.js +19 -23
  38. package/dist/admin/forms/useRichtextEditor.js +8 -1
  39. package/dist/admin/global-items.d.ts +9 -0
  40. package/dist/admin/{shared-items.js → global-items.js} +29 -14
  41. package/dist/admin/index.d.ts +3 -3
  42. package/dist/admin/index.js +1 -1
  43. package/dist/admin/next/op-handler.js +4 -3
  44. package/dist/admin/ops-impl.d.ts +3 -0
  45. package/dist/admin/ops-impl.js +242 -56
  46. package/dist/admin/ops.d.ts +38 -6
  47. package/dist/admin/serialize.d.ts +18 -7
  48. package/dist/admin/serialize.js +27 -5
  49. package/dist/admin/shell/AdminApp.js +7 -7
  50. package/dist/admin/shell/EntriesList.js +19 -3
  51. package/dist/admin/shell/EntryForm.js +183 -77
  52. package/dist/admin/shell/GlobalFields.d.ts +24 -0
  53. package/dist/admin/shell/GlobalFields.js +19 -0
  54. package/dist/admin/shell/{SharedForm.d.ts → GlobalForm.d.ts} +1 -1
  55. package/dist/admin/shell/GlobalForm.js +258 -0
  56. package/dist/admin/shell/{SharedList.d.ts → GlobalList.d.ts} +1 -1
  57. package/dist/admin/shell/{SharedList.js → GlobalList.js} +12 -11
  58. package/dist/admin/shell/IconRail.js +14 -7
  59. package/dist/admin/shell/PagesList.js +4 -8
  60. package/dist/admin/shell/entries-list.d.ts +2 -2
  61. package/dist/admin/shell/entries-list.js +3 -3
  62. package/dist/admin/shell/{shared-list.d.ts → global-list.d.ts} +3 -3
  63. package/dist/admin/shell/{shared-list.js → global-list.js} +2 -2
  64. package/dist/admin/supabase-auth.js +1 -1
  65. package/dist/admin/tree-ops.d.ts +10 -8
  66. package/dist/admin/tree-ops.js +28 -16
  67. package/dist/admin/ui/ActionMenu.d.ts +11 -0
  68. package/dist/admin/ui/ActionMenu.js +25 -0
  69. package/dist/admin/ui/HistoryModal.d.ts +23 -0
  70. package/dist/admin/ui/HistoryModal.js +40 -0
  71. package/dist/admin/ui/Modal.d.ts +5 -1
  72. package/dist/admin/ui/Modal.js +3 -3
  73. package/dist/admin/ui/PublishControl.d.ts +31 -0
  74. package/dist/admin/ui/PublishControl.js +24 -0
  75. package/dist/admin/ui/format.d.ts +4 -0
  76. package/dist/admin/ui/format.js +9 -0
  77. package/dist/admin/ui/primitives.d.ts +16 -1
  78. package/dist/admin/ui/primitives.js +20 -4
  79. package/dist/admin/ui/status-label.d.ts +10 -0
  80. package/dist/admin/ui/status-label.js +14 -0
  81. package/dist/admin/ui/theme.js +93 -13
  82. package/dist/admin/validate.d.ts +27 -2
  83. package/dist/admin/validate.js +47 -10
  84. package/dist/collections.d.ts +3 -9
  85. package/dist/collections.js +1 -2
  86. package/dist/conditions.d.ts +33 -0
  87. package/dist/conditions.js +79 -0
  88. package/dist/config.d.ts +26 -19
  89. package/dist/config.js +26 -25
  90. package/dist/css.d.ts +7 -0
  91. package/dist/css.js +57 -0
  92. package/dist/custom/CustomSection.d.ts +26 -0
  93. package/dist/custom/CustomSection.js +20 -0
  94. package/dist/custom/css.d.ts +3 -0
  95. package/dist/custom/css.js +28 -0
  96. package/dist/custom/index.d.ts +26 -0
  97. package/dist/custom/index.js +16 -0
  98. package/dist/custom/rows.d.ts +28 -0
  99. package/dist/custom/rows.js +129 -0
  100. package/dist/custom/schema.d.ts +32 -0
  101. package/dist/custom/schema.js +39 -0
  102. package/dist/entry-store.d.ts +42 -17
  103. package/dist/entry-store.js +107 -40
  104. package/dist/fields.d.ts +5 -2
  105. package/dist/fields.js +4 -2
  106. package/dist/{shared-id.d.ts → global-id.d.ts} +1 -1
  107. package/dist/{shared-id.js → global-id.js} +4 -4
  108. package/dist/global.d.ts +57 -0
  109. package/dist/global.js +51 -0
  110. package/dist/index.d.ts +28 -18
  111. package/dist/index.js +10 -5
  112. package/dist/media.js +1 -1
  113. package/dist/next/index.d.ts +1 -1
  114. package/dist/next/page-renderer.d.ts +4 -2
  115. package/dist/next/page-renderer.js +17 -13
  116. package/dist/next/queries.d.ts +6 -6
  117. package/dist/next/queries.js +7 -7
  118. package/dist/page.js +2 -2
  119. package/dist/pairing.d.ts +13 -7
  120. package/dist/pairing.js +4 -4
  121. package/dist/refs.d.ts +2 -2
  122. package/dist/refs.js +4 -4
  123. package/dist/render.d.ts +13 -1
  124. package/dist/render.js +10 -5
  125. package/dist/resolve.d.ts +11 -6
  126. package/dist/resolve.js +22 -12
  127. package/dist/revalidate.d.ts +4 -4
  128. package/dist/revalidate.js +4 -4
  129. package/dist/richtext-render.js +2 -2
  130. package/dist/richtext-walk.d.ts +5 -5
  131. package/dist/richtext-walk.js +4 -4
  132. package/dist/schema.d.ts +15 -10
  133. package/dist/schema.js +19 -7
  134. package/dist/section-wrapper.d.ts +18 -6
  135. package/dist/section-wrapper.js +24 -17
  136. package/dist/site.d.ts +17 -5
  137. package/dist/site.js +36 -12
  138. package/dist/sql-space.js +1 -1
  139. package/dist/sql.js +19 -16
  140. package/dist/store.d.ts +45 -13
  141. package/dist/store.js +88 -12
  142. package/dist/styles.d.ts +27 -0
  143. package/dist/styles.js +60 -0
  144. package/dist/supabase-entry-store.d.ts +11 -6
  145. package/dist/supabase-entry-store.js +115 -39
  146. package/dist/supabase-store.d.ts +13 -6
  147. package/dist/supabase-store.js +90 -20
  148. package/dist/zones.d.ts +9 -7
  149. package/dist/zones.js +5 -5
  150. package/package.json +1 -1
  151. package/dist/admin/shared-items.d.ts +0 -9
  152. package/dist/admin/shell/SharedForm.js +0 -134
  153. package/dist/shared.d.ts +0 -46
  154. package/dist/shared.js +0 -36
@@ -0,0 +1,24 @@
1
+ import type { AdminGlobal } from "../serialize.ts";
2
+ import type { Group } from "../forms/tabs.ts";
3
+ import type { GlobalEditItem } from "./global-edit.ts";
4
+ import { type RefOption } from "../shell/GlobalFields.tsx";
5
+ import { type PublishBusy } from "../ui/PublishControl.tsx";
6
+ export declare function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }: {
7
+ item: AdminGlobal;
8
+ /** null while the item loads. */
9
+ edit: GlobalEditItem | null;
10
+ locale: string;
11
+ /** The active tab's groups (spec 2026-09-14 §5). */
12
+ groups: Group[];
13
+ multilingual: boolean;
14
+ refOptions: Record<string, RefOption[]>;
15
+ inlinePath?: string;
16
+ onEditHere?(): void;
17
+ onChange(ns: "fields" | "settings" | "styles" | "css", key: string, value: unknown): void;
18
+ /** An edit the store has not confirmed (dirty, or the item's autosave not in `saved`). */
19
+ pending: boolean;
20
+ busy: PublishBusy;
21
+ onPublish(): void;
22
+ onDiscard(): void;
23
+ onUnpublish(): void;
24
+ }): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { BASE } from "../shell/base.js";
4
+ import { localeStatusOf } from "../shell/pages-tree.js";
5
+ import { GlobalFields } from "../shell/GlobalFields.js";
6
+ import { PublishControl } from "../ui/PublishControl.js";
7
+ export function GlobalInspector({ item, edit, locale, groups, multilingual, refOptions, inlinePath, onEditHere, onChange, pending, busy, onPublish, onDiscard, onUnpublish, }) {
8
+ const row = edit?.record.locales[locale];
9
+ // The same three states the global list shows; the control decides what applies.
10
+ const status = row ? localeStatusOf(row) : "draft";
11
+ const pages = edit ? [...new Set(edit.usage.filter((u) => u.sourceKind === "page").map((u) => u.sourceId))] : [];
12
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [_jsx("p", { className: "sm-hint", children: "Global content \u2014 changes every page it is used on." }), edit && (_jsxs("p", { className: "sm-hint", children: [pages.length === 0 ? "Not placed on any page." : `Used on ${pages.length} page${pages.length === 1 ? "" : "s"}: `, pages.map((id, i) => (_jsxs("span", { children: [i > 0 && ", ", _jsx("a", { href: `${BASE}/pages/${encodeURIComponent(id)}?locale=${encodeURIComponent(locale)}`, children: id })] }, id)))] })), _jsx("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }, children: _jsx(PublishControl, { status: row ? status : null, pending: pending, busy: busy, disabled: !edit, onPublish: onPublish, onDiscard: onDiscard, onUnpublish: onUnpublish }) }), edit ? (_jsx(GlobalFields, { item: item, groups: groups, fields: edit.fields, errors: edit.errors, refOptions: refOptions, multilingual: multilingual, inlinePath: inlinePath, onEditHere: onEditHere, onChange: onChange })) : (_jsx("p", { style: { color: "var(--text-muted)" }, children: "Loading\u2026" }))] }));
13
+ }
@@ -1,18 +1,19 @@
1
1
  import type { ZonePolicy } from "../../zones.ts";
2
- import type { AdminRegistry, AdminSection, AdminShared } from "../serialize.ts";
2
+ import type { AdminRegistry, AdminSection, AdminGlobal } from "../serialize.ts";
3
3
  export type PickerAt = {
4
4
  zone: string;
5
5
  index: number;
6
6
  x: number;
7
7
  y: number;
8
8
  };
9
- export declare function InsertPicker({ at, policy, registry, sharedIds, onInsert, onInsertShared, onClose }: {
9
+ export declare function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }: {
10
10
  at: PickerAt;
11
11
  policy: ZonePolicy | undefined;
12
12
  registry: AdminRegistry;
13
- /** Shared item name → row id; an item without one cannot be placed. */
14
- sharedIds: Record<string, string>;
13
+ /** Global item name → row id; an item without one cannot be placed. */
14
+ globalIds: Record<string, string>;
15
15
  onInsert(section: AdminSection): void;
16
- onInsertShared(item: AdminShared): void;
16
+ onInsertGlobal(item: AdminGlobal): void;
17
+ onInsertCustom(): void;
17
18
  onClose(): void;
18
19
  }): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { allowedSections, allowedShared } from "../tree-ops.js";
3
+ import { allowedSections, allowedGlobals } from "../tree-ops.js";
4
+ import { allowsCustom } from "../custom-section.js";
4
5
  import { SectionLabel } from "../ui/primitives.js";
5
6
  /** Keep the panel on screen; a static render (no window) leaves it where it was asked. */
6
7
  function clamp(at) {
@@ -11,8 +12,9 @@ function clamp(at) {
11
12
  top: Math.min(at.y, Math.max(12, window.innerHeight - 240)),
12
13
  };
13
14
  }
14
- export function InsertPicker({ at, policy, registry, sharedIds, onInsert, onInsertShared, onClose }) {
15
+ export function InsertPicker({ at, policy, registry, globalIds, onInsert, onInsertGlobal, onInsertCustom, onClose }) {
15
16
  const sections = allowedSections(policy, registry.sections);
16
- const shared = allowedShared(policy, registry.shared, sharedIds);
17
- return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", ...clamp(at) }, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), shared.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { mono: true, style: { padding: "8px 8px 4px" }, children: "Shared" }), shared.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertShared(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Shared \u00B7 edited in one place" })] })] }, `shared-${item.name}`)))] })), sections.length === 0 && shared.length === 0 && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
17
+ const globals = allowedGlobals(policy, registry.globals, globalIds);
18
+ const custom = allowsCustom(policy);
19
+ return (_jsx("div", { style: { position: "fixed", inset: 0, zIndex: 60 }, onClick: onClose, children: _jsxs("div", { className: "sm-picker", onClick: (e) => e.stopPropagation(), style: { position: "absolute", ...clamp(at) }, children: [_jsxs(SectionLabel, { mono: true, style: { padding: "6px 8px 8px" }, children: ["Add to ", at.zone] }), sections.map((section) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsert(section), children: [_jsx("span", { className: "sm-picker__icon", children: section.icon ?? "▤" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: section.title }), section.description && (_jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: section.description }))] })] }, section.name))), custom && (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: onInsertCustom, children: [_jsx("span", { className: "sm-picker__icon", children: "\u25A6" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: registry.custom.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: registry.custom.description })] })] })), globals.length > 0 && (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { mono: true, style: { padding: "8px 8px 4px" }, children: "Global" }), globals.map((item) => (_jsxs("button", { type: "button", className: "sm-picker__item", onClick: () => onInsertGlobal(item), children: [_jsx("span", { className: "sm-picker__icon", children: "\u26D3" }), _jsxs("span", { style: { minWidth: 0 }, children: [_jsx("span", { style: { display: "block", fontWeight: 600 }, children: item.title }), _jsx("span", { style: { display: "block", fontSize: "var(--text-sm)", color: "var(--text-muted)" }, children: "Global \u00B7 edited in one place" })] })] }, `global-${item.name}`)))] })), sections.length === 0 && globals.length === 0 && !custom && (_jsx("p", { style: { color: "var(--text-muted)", padding: 10 }, children: "This zone doesn't accept insertions." }))] }) }));
18
20
  }
@@ -0,0 +1,21 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { Group, TabEntry } from "../forms/tabs.ts";
3
+ import type { WidgetScope } from "../forms/FieldWidget.tsx";
4
+ export type NodeValues = {
5
+ fields: Record<string, unknown>;
6
+ settings?: Record<string, unknown>;
7
+ styles?: Record<string, unknown>;
8
+ css?: string;
9
+ };
10
+ /** The entries whose condition does not hold: a bare path reads the entry's own namespace. */
11
+ export declare function visibleEntries(entries: TabEntry[], values: NodeValues): TabEntry[];
12
+ export declare function valueOf(values: NodeValues, entry: TabEntry): unknown;
13
+ export declare function InspectorFields({ groups, values, render }: {
14
+ groups: Group[];
15
+ values: NodeValues;
16
+ /** One widget for one entry. */
17
+ render(entry: TabEntry, opts: {
18
+ emptyLabel?: string;
19
+ scope: WidgetScope;
20
+ }): ReactNode;
21
+ }): ReactElement;
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { isHidden } from "../../conditions.js";
4
+ import { SectionLabel } from "../ui/primitives.js";
5
+ /** The entries whose condition does not hold: a bare path reads the entry's own namespace. */
6
+ export function visibleEntries(entries, values) {
7
+ return entries.filter(({ ns, descriptor }) => ns === "css" || !isHidden(descriptor, { self: values[ns], fields: values.fields, settings: values.settings }));
8
+ }
9
+ export function valueOf(values, entry) {
10
+ if (entry.ns === "css")
11
+ return values.css;
12
+ return values[entry.ns]?.[entry.key];
13
+ }
14
+ export function InspectorFields({ groups, values, render }) {
15
+ const scope = { fields: values.fields, settings: values.settings };
16
+ return (_jsx(_Fragment, { children: groups.map((g, i) => {
17
+ const entries = visibleEntries(g.entries, values);
18
+ if (entries.length === 0)
19
+ return null;
20
+ return (_jsxs("div", { className: "sm-inspector__group", children: [g.title && _jsx(SectionLabel, { mono: true, children: g.title }), entries.map((e) => render(e, { emptyLabel: e.ns === "styles" ? "Default" : undefined, scope }))] }, g.title ?? i));
21
+ }) }));
22
+ }
@@ -13,7 +13,6 @@ type Handlers = {
13
13
  onSelect(id: string): void;
14
14
  /** The empty row of an insertable zone was clicked. */
15
15
  onAddFirst(zone: string): void;
16
- onMove(id: string, dir: -1 | 1): void;
17
16
  /** The drop and the grip's arrow keys: `index` in the resulting order. */
18
17
  onMoveTo(id: string, index: number): void;
19
18
  onRemove(id: string): void;
@@ -3,13 +3,13 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { Grip, IconButton, SectionLabel, cx } from "../ui/primitives.js";
4
4
  import { useSortable } from "../ui/useSortable.js";
5
5
  export function Outline({ zones, ...handlers }) {
6
- return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Outline" }), zones.map((z) => _jsx(OutlineZone, { ...z, ...handlers }, z.zone))] }));
6
+ return (_jsxs(_Fragment, { children: [_jsx(SectionLabel, { children: "Page Outline" }), zones.map((z) => _jsx(OutlineZone, { ...z, ...handlers }, z.zone))] }));
7
7
  }
8
- function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAddFirst, onMove, onMoveTo, onRemove }) {
8
+ function OutlineZone({ zone, locked, canTake, nodes, selectedId, onSelect, onAddFirst, onMoveTo, onRemove }) {
9
9
  const sortable = useSortable({
10
10
  count: locked ? 0 : nodes.length,
11
11
  onDrop: (from, to) => onMoveTo(nodes[from].node.id, to),
12
12
  });
13
13
  return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs(SectionLabel, { mono: true, children: [zone, locked && " · locked"] }), nodes.length === 0 && (_jsxs("div", { className: cx("sm-outline-row", "sm-outline-row--empty", canTake && "sm-outline-row--clickable"), onClick: () => { if (canTake)
14
- onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsxs("span", { style: { display: "flex", gap: 2, flex: "none" }, children: [_jsx(IconButton, { label: "Move up", size: 22, disabled: i === 0, onClick: (e) => { e.stopPropagation(); onMove(node.id, -1); }, children: "\u2191" }), _jsx(IconButton, { label: "Move down", size: 22, disabled: i === nodes.length - 1, onClick: (e) => { e.stopPropagation(); onMove(node.id, 1); }, children: "\u2193" }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] })] }, node.id))) }))] }));
14
+ onAddFirst(zone); }, children: [_jsx("span", { className: "sm-outline-row__label", children: "Empty" }), canTake && _jsx("span", { className: "sm-outline-row__add", children: "+ Add" })] })), locked && nodes.map(({ node, label }) => (_jsxs("div", { className: "sm-outline-row sm-outline-row--locked sm-outline-row--clickable", onClick: () => onSelect(node.id), children: [_jsx("span", { className: "sm-outline-row__label", children: label }), _jsx("span", { className: "sm-outline-row__note", children: "Locked" })] }, node.id))), !locked && nodes.length > 0 && (_jsx("div", { ref: sortable.containerRef, style: { display: "flex", flexDirection: "column", gap: 4 }, children: nodes.map(({ node, label }, i) => (_jsxs("div", { onClick: () => onSelect(node.id), style: sortable.rowStyle(i), className: cx("sm-outline-row", "sm-outline-row--clickable", "sm-outline-row--grip", node.id === selectedId && "sm-outline-row--selected", sortable.rowClass(i)), children: [_jsx(Grip, { ...sortable.handleProps(i) }), _jsx("span", { className: "sm-outline-row__label", children: label }), _jsx(IconButton, { label: "Remove", size: 22, onClick: (e) => { e.stopPropagation(); onRemove(node.id); }, children: "\u00D7" })] }, node.id))) }))] }));
15
15
  }
@@ -8,7 +8,8 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
8
8
  // tab and the remove-from-locale button live there (spec 2026-09-10 page
9
9
  // fields §5).
10
10
  import { useEffect, useState } from "react";
11
- import { Field, SectionLabel } from "../ui/primitives.js";
11
+ import { updatedLine } from "../ui/format.js";
12
+ import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
12
13
  export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
13
14
  const row = record.locales[locale];
14
15
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
@@ -37,5 +38,5 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
37
38
  void commit(key);
38
39
  }
39
40
  };
40
- return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) })] }));
41
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsxs(SectionLabel, { children: ["Page \u00B7 ", locale] }), fixed && _jsx("span", { className: "sm-hint", children: "Owned by code \u2014 change it in the registration." }), error && _jsx("span", { className: "sm-error", children: error }), _jsx(Field, { label: "Title", children: _jsx("input", { className: "sm-input", disabled: fixed, value: draft.title, onChange: (e) => setDraft((d) => ({ ...d, title: e.target.value })), onBlur: () => void commit("title"), onKeyDown: onKey("title") }) }), _jsx(Field, { label: "Slug", hint: path ?? "", children: _jsx("input", { className: "sm-input sm-input--mono", disabled: fixed || isHome, value: draft.slug, onChange: (e) => setDraft((d) => ({ ...d, slug: e.target.value })), onBlur: () => void commit("slug"), onKeyDown: onKey("slug") }) }), _jsx(SectionLabel, { children: "Document info" }), _jsx(InfoRow, { label: "Updated", value: updatedLine(row.updatedAt, row.updatedBy) }), _jsx(InfoRow, { label: "ID", value: record.id, mono: true })] }));
41
42
  }
@@ -0,0 +1,39 @@
1
+ import type { EntryRecord, EntryUsage } from "../../entry-store.ts";
2
+ import type { AdminOps, OpResult } from "../ops.ts";
3
+ import { type SaveState } from "./autosave.ts";
4
+ export type GlobalEditItem = {
5
+ name: string;
6
+ locale: string;
7
+ record: EntryRecord;
8
+ /** The merged draft fields; a visual item's settings under `$settings`. */
9
+ fields: Record<string, unknown>;
10
+ usage: EntryUsage[];
11
+ /** The last save's field errors by dotted path; cleared by a successful save. */
12
+ errors: Record<string, string>;
13
+ };
14
+ export type GlobalEditState = {
15
+ items: Record<string, GlobalEditItem>;
16
+ saveState: SaveState;
17
+ loadError: string | null;
18
+ };
19
+ export type GlobalEdit = {
20
+ getState(): GlobalEditState;
21
+ subscribe(listener: () => void): () => void;
22
+ /** Load an item for a locale once; a repeat while loaded or loading is a no-op. */
23
+ load(name: string, locale: string): Promise<void>;
24
+ /** Set the value at a dotted path (`tagline`, `$settings.tone`, `columns.1.title`); a debounced save follows. */
25
+ patch(name: string, path: string, value: unknown): void;
26
+ flush(): Promise<boolean>;
27
+ /** Flush, then set the item's status in its locale; the record updates on success. */
28
+ setStatus(name: string, status: "draft" | "published"): Promise<OpResult<EntryRecord>>;
29
+ /** Drop every item (a new locale, a new canvas). Flush BEFORE calling this. */
30
+ reset(): void;
31
+ dispose(): void;
32
+ };
33
+ export declare function createGlobalEdit(opts: {
34
+ ops: Pick<AdminOps, "globals">;
35
+ onSaved?: () => void;
36
+ debounceMs?: number;
37
+ retryBaseMs?: number;
38
+ retryCapMs?: number;
39
+ }): GlobalEdit;
@@ -0,0 +1,124 @@
1
+ import { setAtPath } from "../field-paths.js";
2
+ import { createDraftAutosave } from "./autosave.js";
3
+ export function createGlobalEdit(opts) {
4
+ let items = {};
5
+ let loadError = null;
6
+ let generation = 0;
7
+ const loading = new Set();
8
+ const rev = new Map();
9
+ const savedRev = new Map();
10
+ const listeners = new Set();
11
+ let snapshot = null;
12
+ const emit = () => { snapshot = null; for (const l of listeners)
13
+ l(); };
14
+ const dirty = () => Object.values(items).filter((i) => (rev.get(i.name) ?? 0) > (savedRev.get(i.name) ?? 0));
15
+ const autosave = createDraftAutosave({
16
+ read: () => { const d = dirty(); return d.length > 0 ? d : null; },
17
+ persist: async (list) => {
18
+ let failed = null;
19
+ for (const item of list) {
20
+ const at = rev.get(item.name) ?? 0;
21
+ let r;
22
+ try {
23
+ r = await opts.ops.globals.save(item.name, item.locale, item.fields);
24
+ }
25
+ catch {
26
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
27
+ }
28
+ const current = items[item.name];
29
+ if (r.ok) {
30
+ savedRev.set(item.name, at);
31
+ if (current)
32
+ items = { ...items, [item.name]: { ...current, record: r.data, errors: {} } };
33
+ }
34
+ else {
35
+ failed ?? (failed = r);
36
+ if (current) {
37
+ items = { ...items, [item.name]: { ...current, errors: Object.fromEntries((r.fields ?? []).map((f) => [f.field, f.message])) } };
38
+ }
39
+ }
40
+ }
41
+ emit();
42
+ return failed ?? { ok: true, data: null };
43
+ },
44
+ onSaved: opts.onSaved,
45
+ debounceMs: opts.debounceMs,
46
+ retryBaseMs: opts.retryBaseMs,
47
+ retryCapMs: opts.retryCapMs,
48
+ });
49
+ autosave.subscribe(emit);
50
+ const getState = () => (snapshot ?? (snapshot = { items, saveState: autosave.getState(), loadError }));
51
+ return {
52
+ getState,
53
+ subscribe(listener) {
54
+ listeners.add(listener);
55
+ return () => { listeners.delete(listener); };
56
+ },
57
+ async load(name, locale) {
58
+ const key = `${name}/${locale}`;
59
+ if (items[name]?.locale === locale || loading.has(key))
60
+ return;
61
+ loading.add(key);
62
+ const gen = generation;
63
+ let r;
64
+ try {
65
+ r = await opts.ops.globals.get(name, locale);
66
+ }
67
+ catch {
68
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
69
+ }
70
+ loading.delete(key);
71
+ if (gen !== generation)
72
+ return; // reset() ran meanwhile — this canvas is gone
73
+ if (!r.ok) {
74
+ loadError = r.message;
75
+ emit();
76
+ return;
77
+ }
78
+ items = { ...items, [name]: { name, locale, record: r.data.record, fields: r.data.fields, usage: r.data.usage, errors: {} } };
79
+ loadError = null;
80
+ emit();
81
+ },
82
+ patch(name, path, value) {
83
+ const item = items[name];
84
+ if (!item)
85
+ return;
86
+ items = { ...items, [name]: { ...item, fields: setAtPath(item.fields, path, value) } };
87
+ rev.set(name, (rev.get(name) ?? 0) + 1);
88
+ emit();
89
+ autosave.mark();
90
+ },
91
+ flush: autosave.flush,
92
+ async setStatus(name, status) {
93
+ const item = items[name];
94
+ if (!item)
95
+ return { ok: false, code: "not_found", message: "This item hasn't loaded yet." };
96
+ if (!(await autosave.flush())) {
97
+ return { ok: false, code: "internal", message: "Couldn't save your latest changes — publish cancelled." };
98
+ }
99
+ let r;
100
+ try {
101
+ r = await opts.ops.globals.setStatus(name, item.locale, status);
102
+ }
103
+ catch {
104
+ r = { ok: false, code: "internal", message: "Couldn't reach the server." };
105
+ }
106
+ if (r.ok && items[name]) {
107
+ items = { ...items, [name]: { ...items[name], record: r.data } };
108
+ emit();
109
+ }
110
+ return r;
111
+ },
112
+ reset() {
113
+ generation += 1;
114
+ items = {};
115
+ rev.clear();
116
+ savedRev.clear();
117
+ loading.clear();
118
+ loadError = null;
119
+ autosave.dispose();
120
+ emit();
121
+ },
122
+ dispose: autosave.dispose,
123
+ };
124
+ }
@@ -0,0 +1,6 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ import { type GlobalEdit, type GlobalEditState } from "./global-edit.ts";
3
+ export declare function useGlobalEdit({ ops, onSaved }: {
4
+ ops: Pick<AdminOps, "globals">;
5
+ onSaved?: () => void;
6
+ }): GlobalEditState & Pick<GlobalEdit, "load" | "patch" | "flush" | "setStatus" | "reset">;
@@ -0,0 +1,18 @@
1
+ // React glue for `createGlobalEdit`: one controller per editor mount, the
2
+ // latest `onSaved` through a ref (it closes over the inline session), the
3
+ // state through useSyncExternalStore. On unmount a pending save is
4
+ // flushed first — the server call outlives the view — then the timers stop.
5
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
6
+ import { createGlobalEdit } from "./global-edit.js";
7
+ export function useGlobalEdit({ ops, onSaved }) {
8
+ const latest = useRef(onSaved);
9
+ latest.current = onSaved;
10
+ const [controller] = useState(() => createGlobalEdit({ ops, onSaved: () => latest.current?.() }));
11
+ useEffect(() => () => { void controller.flush(); controller.dispose(); }, [controller]);
12
+ const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
13
+ return {
14
+ ...state,
15
+ load: controller.load, patch: controller.patch, flush: controller.flush,
16
+ setStatus: controller.setStatus, reset: controller.reset,
17
+ };
18
+ }
@@ -1,6 +1,11 @@
1
1
  import type { ReactElement, ReactNode } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
- export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, }: {
3
+ /** The two namespaces a `fields.` / `settings.` condition reads (spec 2026-09-14 §12). */
4
+ export type WidgetScope = {
5
+ fields?: Record<string, unknown>;
6
+ settings?: Record<string, unknown>;
7
+ };
8
+ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions, fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }: {
4
9
  descriptor: AdminField;
5
10
  value: unknown;
6
11
  onChange(value: unknown): void;
@@ -21,4 +26,12 @@ export declare function FieldWidget({ descriptor: d, value: valueProp, onChange,
21
26
  /** The path of the field being edited in the canvas: its richtext widget yields to a note. */
22
27
  inlinePath?: string;
23
28
  onEditHere?(): void;
29
+ /** The node's top-level values, for a subfield hidden by `fields.x` / `settings.x`. */
30
+ scope?: WidgetScope;
31
+ /** An optional radio's empty pill; `None` unless the caller says otherwise (`Default` for a styles step). */
32
+ emptyLabel?: string;
24
33
  }): ReactElement;
34
+ /** The richtext field while it is edited in the canvas (spec 2026-09-10 §5). */
35
+ export declare function InlineNote({ onEditHere }: {
36
+ onEditHere?(): void;
37
+ }): import("react").JSX.Element;
@@ -3,16 +3,25 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { RichtextWidget } from "./RichtextWidget.js";
4
4
  import { MediaWidget } from "./MediaWidget.js";
5
5
  import { ListField } from "./ListField.js";
6
+ import { RowsWidget } from "./RowsWidget.js";
6
7
  import { optionsOf } from "../../fields.js";
8
+ import { isHidden } from "../../conditions.js";
9
+ import { cssProblem } from "../../css.js";
7
10
  import { Button, Field, IconButton, Switch } from "../ui/primitives.js";
8
11
  import { patchObject } from "./list.js";
9
- const label = (key) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
10
- export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, }) {
12
+ /** A key as a caption, sentence case: `spacingTop` → "Spacing top", `css` → "Custom CSS". */
13
+ const label = (key) => key === "css" ? "Custom CSS" : key.replace(/([A-Z])/g, (c) => ` ${c.toLowerCase()}`).replace(/^./, (c) => c.toUpperCase());
14
+ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors, path: pathProp, bare, refOptions = [], fieldKey, prefix, mark, inlinePath, onEditHere, scope, emptyLabel, }) {
11
15
  const path = pathProp ?? fieldKey;
12
16
  const message = errors?.[path];
13
17
  // The descriptor's default shows while the value is absent (spec 2026-09-10 page fields §1).
14
18
  const value = valueProp === undefined && d.default !== undefined ? d.default : valueProp;
15
19
  const optionTitle = (o) => o.status === "draft" ? `${o.title} (draft)` : o.title;
20
+ // The custom section's rows (spec 2026-09-14 §6): the row cards instead of the generic list.
21
+ if (d.widget === "rows") {
22
+ const rows = _jsx(RowsWidget, { descriptor: d, value: value, onChange: onChange, errors: errors, path: path, inlinePath: inlinePath, onEditHere: onEditHere });
23
+ return bare ? rows : _jsx(Field, { label: label(fieldKey), error: message, asDiv: true, path: path, children: rows });
24
+ }
16
25
  let control;
17
26
  let counter;
18
27
  let asDiv = d.type === "refList";
@@ -39,8 +48,23 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
39
48
  break;
40
49
  }
41
50
  case "radio": {
42
- asDiv = true; // several inputs: never inside one implicit label
43
- control = (_jsx("div", { className: "sm-choices", role: "radiogroup", children: optionsOf(d.options).map((o) => (_jsxs("label", { className: "sm-choice", children: [_jsx("input", { type: "radio", name: path, value: o.value, checked: value === o.value, onChange: () => onChange(o.value) }), o.label] }, o.value))) }));
51
+ asDiv = true; // a row of buttons: never inside one implicit label
52
+ // Pills (spec 2026-09-14 §5); an optional radio starts with the empty pill, which stores nothing.
53
+ const empty = value === undefined || value === "";
54
+ const pills = [
55
+ ...(d.optional === true ? [{ value: undefined, label: emptyLabel ?? "None" }] : []),
56
+ ...optionsOf(d.options),
57
+ ];
58
+ control = (_jsx("div", { className: "sm-pills", role: "radiogroup", children: pills.map((o) => (_jsx("button", { type: "button", className: "sm-pill", "aria-pressed": o.value === undefined ? empty : value === o.value, onClick: () => onChange(o.value), children: o.label }, o.value ?? ""))) }));
59
+ break;
60
+ }
61
+ case "css": {
62
+ // The custom-CSS box (spec 2026-09-14 §4): the refusal shows as it is typed; publish refuses it too.
63
+ const text = typeof value === "string" ? value : "";
64
+ const problem = text === "" ? null : cssProblem(text);
65
+ control = (_jsx("textarea", { className: "sm-textarea sm-css", rows: 6, spellCheck: false, value: text, onChange: (e) => onChange(e.target.value === "" ? undefined : e.target.value) }));
66
+ if (problem)
67
+ return _jsx(Field, { label: label(fieldKey), error: problem, path: path, children: control });
44
68
  break;
45
69
  }
46
70
  case "checkboxes": {
@@ -77,17 +101,20 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
77
101
  case "object": {
78
102
  asDiv = true;
79
103
  const obj = (value && typeof value === "object" && !Array.isArray(value) ? value : {});
80
- control = (_jsx("div", { className: "sm-group", children: Object.entries(d.fields ?? {}).map(([k, sub]) => (_jsx(FieldWidget, { fieldKey: k, descriptor: sub, value: obj[k], errors: errors, path: `${path}.${k}`, inlinePath: inlinePath, onEditHere: onEditHere, onChange: (v) => onChange(patchObject(obj, k, v, d.optional === true)) }, k))) }));
104
+ control = (_jsx("div", { className: "sm-group", children: Object.entries(d.fields ?? {})
105
+ .filter(([, sub]) => !isHidden(sub, { self: obj, fields: scope?.fields, settings: scope?.settings }))
106
+ .map(([k, sub]) => (_jsx(FieldWidget, { fieldKey: k, descriptor: sub, value: obj[k], errors: errors, path: `${path}.${k}`, inlinePath: inlinePath, onEditHere: onEditHere, scope: scope, onChange: (v) => onChange(patchObject(obj, k, v, d.optional === true)) }, k))) }));
81
107
  break;
82
108
  }
83
109
  case "list": {
84
- asDiv = true;
110
+ // The list renders its own head (label, counter, Collapse all) so the toggle sits beside the label.
85
111
  const items = Array.isArray(value) ? value : [];
86
112
  const item = d.item ?? { type: "text" };
87
- if (typeof d.max === "number")
88
- counter = `${items.length} of ${d.max}`;
89
- control = (_jsx(ListField, { descriptor: d, items: items, onChange: onChange, path: path, renderItem: (it, i) => (_jsx(FieldWidget, { fieldKey: String(i), descriptor: item, value: it, errors: errors, path: `${path}.${i}`, bare: true, inlinePath: inlinePath, onEditHere: onEditHere, onChange: (v) => onChange(items.map((x, j) => (j === i ? v : x))) })) }));
90
- break;
113
+ const count = typeof d.max === "number" ? `${items.length} of ${d.max}` : undefined;
114
+ const list = (_jsx(ListField, { descriptor: d, items: items, onChange: onChange, path: path, head: bare ? undefined : { label: label(fieldKey), mark, counter: count, error: message }, renderItem: (it, i) => (_jsx(FieldWidget, { fieldKey: String(i), descriptor: item, value: it, errors: errors, path: `${path}.${i}`, bare: true, inlinePath: inlinePath, onEditHere: onEditHere, scope: scope, onChange: (v) => onChange(items.map((x, j) => (j === i ? v : x))) })) }));
115
+ if (!bare)
116
+ return list;
117
+ return _jsxs(_Fragment, { children: [list, message && _jsx("span", { className: "sm-error", children: message })] });
91
118
  }
92
119
  default:
93
120
  control = _jsx("textarea", { className: "sm-textarea", disabled: true, value: JSON.stringify(value ?? null, null, 2), rows: 2 });
@@ -101,6 +128,6 @@ export function FieldWidget({ descriptor: d, value: valueProp, onChange, errors,
101
128
  return (_jsx(Field, { label: label(fieldKey), mark: mark, counter: counter, error: message, asDiv: asDiv, path: path, children: control }));
102
129
  }
103
130
  /** The richtext field while it is edited in the canvas (spec 2026-09-10 §5). */
104
- function InlineNote({ onEditHere }) {
131
+ export function InlineNote({ onEditHere }) {
105
132
  return (_jsxs("div", { className: "sm-inline-note", children: [_jsx("span", { children: "Editing in the canvas" }), _jsx(Button, { variant: "secondary", onClick: onEditHere, children: "Edit here" })] }));
106
133
  }
@@ -1,10 +1,18 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { AdminField } from "../serialize.ts";
3
- export declare function ListField({ descriptor: d, items, onChange, renderItem, path }: {
3
+ export declare function ListField({ descriptor: d, items, onChange, renderItem, path, head }: {
4
4
  descriptor: AdminField;
5
5
  items: unknown[];
6
6
  onChange(value: unknown[]): void;
7
7
  renderItem(item: unknown, index: number): ReactNode;
8
8
  /** The list's dotted path; each row carries `<path>.<i>` for the canvas reveal. */
9
9
  path?: string;
10
+ /** The field head to render around the rows (label, mark, counter, error), so the
11
+ * collapse toggle can sit in it; absent for a bare list (a list item). */
12
+ head?: {
13
+ label: ReactNode;
14
+ mark?: ReactNode;
15
+ counter?: ReactNode;
16
+ error?: ReactNode;
17
+ };
10
18
  }): import("react").JSX.Element;
@@ -1,15 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- // A list field's rows: collapse per row, add and remove, and reordering
4
- // by drag (useSortable), by the arrow keys on the grip, or by the ↑/↓
5
- // buttons — every path through the pure helpers in ./list.ts. The item
6
- // widget is rendered by the caller (FieldWidget), which keeps this file
7
- // free of the recursion.
3
+ // A list field's rows: collapse per row (and all at once from the head's
4
+ // Collapse all / Expand all), add and remove, and reordering by drag
5
+ // (useSortable), by the arrow keys on the grip, or by the ↑/↓ buttons —
6
+ // every path through the pure helpers in ./list.ts. The item widget is
7
+ // rendered by the caller (FieldWidget), which keeps this file free of
8
+ // the recursion.
8
9
  import { useState } from "react";
9
- import { Button, Grip, IconButton } from "../ui/primitives.js";
10
+ import { Button, Field, Grip, IconButton } from "../ui/primitives.js";
10
11
  import { useSortable } from "../ui/useSortable.js";
11
12
  import { addItem, itemSummary, moveIndex, moveItem, removeIndex, removeItem } from "./list.js";
12
- export function ListField({ descriptor: d, items, onChange, renderItem, path }) {
13
+ export function ListField({ descriptor: d, items, onChange, renderItem, path, head }) {
13
14
  // Keyed to the item's index rather than to `key={i}` position, so a move
14
15
  // or remove doesn't hand a row someone else's open/closed state.
15
16
  const [collapsed, setCollapsed] = useState(() => new Set());
@@ -22,7 +23,10 @@ export function ListField({ descriptor: d, items, onChange, renderItem, path })
22
23
  setCollapsed((prev) => moveIndex(prev, from, to));
23
24
  };
24
25
  const sortable = useSortable({ count: items.length, onDrop: move });
25
- return (_jsxs("div", { className: "sm-list", ref: sortable.containerRef, children: [items.map((it, i) => (_jsx(ListRow, { index: i, count: items.length, summary: itemSummary(it, i), open: !collapsed.has(i), path: path === undefined ? undefined : `${path}.${i}`, style: sortable.rowStyle(i), className: sortable.rowClass(i), grip: _jsx(Grip, { ...sortable.handleProps(i) }), onToggle: () => setCollapsed((prev) => {
26
+ // One toggle for the whole list: Collapse all until every row is closed, then Expand all.
27
+ const allCollapsed = items.length > 0 && items.every((_, i) => collapsed.has(i));
28
+ const toggleAll = items.length > 1 && (_jsx("button", { type: "button", className: "sm-field__action", onClick: () => setCollapsed(allCollapsed ? new Set() : new Set(items.map((_, i) => i))), children: allCollapsed ? "Expand all" : "Collapse all" }));
29
+ const rows = (_jsxs("div", { className: "sm-list", ref: sortable.containerRef, children: [items.map((it, i) => (_jsx(ListRow, { index: i, count: items.length, summary: itemSummary(it, i), open: !collapsed.has(i), path: path === undefined ? undefined : `${path}.${i}`, style: sortable.rowStyle(i), className: sortable.rowClass(i), grip: _jsx(Grip, { ...sortable.handleProps(i) }), onToggle: () => setCollapsed((prev) => {
26
30
  const next = new Set(prev);
27
31
  if (next.has(i))
28
32
  next.delete(i);
@@ -33,6 +37,10 @@ export function ListField({ descriptor: d, items, onChange, renderItem, path })
33
37
  onChange(removeItem(items, i));
34
38
  setCollapsed((prev) => removeIndex(prev, i));
35
39
  }, children: renderItem(it, i) }, i))), _jsx(Button, { variant: "secondary", disabled: atMax, onClick: () => onChange(addItem(items, d)), children: "Add" })] }));
40
+ if (!head)
41
+ return rows;
42
+ // A div, not a label: a label would press the first row's button on any click.
43
+ return (_jsx(Field, { label: head.label, mark: head.mark, counter: head.counter, action: toggleAll || undefined, error: head.error, asDiv: true, path: path, children: rows }));
36
44
  }
37
45
  /** One list item: a header with the grip, the summary and the row
38
46
  * controls, the item's widget underneath while expanded. Header is a