@drawnagency/primitives 0.1.56 → 0.1.58

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 (165) hide show
  1. package/dist/adapter-HH47ZPGM.js +1779 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/{chunk-EU6NZ4GS.js → chunk-AN62WPW7.js} +22 -163
  4. package/dist/chunk-ESE5UBQI.js +73 -0
  5. package/dist/{chunk-KGYWQDBB.js → chunk-ICLXLWQ5.js} +9 -72
  6. package/dist/chunk-JSBRDJBE.js +30 -0
  7. package/dist/chunk-NSCT3AMV.js +32 -0
  8. package/dist/chunk-RFZNNCAS.js +160 -0
  9. package/dist/chunk-TG43X7JO.js +123 -0
  10. package/dist/{chunk-7IAWF7LE.js → chunk-V7JN2DDU.js} +2 -19
  11. package/dist/chunk-VKAGMEKE.js +90 -0
  12. package/dist/chunk-ZU2MKPTG.js +29 -0
  13. package/dist/closest-edge-EBOXL3YW.js +72 -0
  14. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  15. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  16. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  17. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  18. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  19. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  20. package/dist/components/editor/SettingsForm.d.ts +5 -1
  21. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  22. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  23. package/dist/components/primitives/IconPicker.d.ts +7 -1
  24. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  25. package/dist/components/sections/Container/Container.d.ts +20 -0
  26. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  27. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  28. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  29. package/dist/components/sections/Container/index.d.ts +11 -0
  30. package/dist/components/sections/Container/index.d.ts.map +1 -0
  31. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  32. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  33. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  34. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  35. package/dist/components/sections/IconList/index.d.ts +1 -0
  36. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  37. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  38. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  39. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  40. package/dist/components/sections/Media/index.d.ts.map +1 -0
  41. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  42. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  43. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  44. package/dist/components/sections/Spacer/index.d.ts +6 -0
  45. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  46. package/dist/components/sections/all-sections.d.ts +29 -103
  47. package/dist/components/sections/all-sections.d.ts.map +1 -1
  48. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  49. package/dist/components/sections/register-schemas.js +4094 -0
  50. package/dist/components/shared/Tabs.d.ts +24 -0
  51. package/dist/components/shared/Tabs.d.ts.map +1 -0
  52. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  53. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  54. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  55. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  56. package/dist/hooks/useBlockDnd.d.ts +48 -0
  57. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  58. package/dist/index.js +69 -56
  59. package/dist/lib/block-dnd.d.ts +42 -0
  60. package/dist/lib/block-dnd.d.ts.map +1 -0
  61. package/dist/lib/block-move.d.ts +31 -0
  62. package/dist/lib/block-move.d.ts.map +1 -0
  63. package/dist/lib/container-grid.d.ts +29 -0
  64. package/dist/lib/container-grid.d.ts.map +1 -0
  65. package/dist/lib/container-ops.d.ts +44 -0
  66. package/dist/lib/container-ops.d.ts.map +1 -0
  67. package/dist/lib/dexie.d.ts.map +1 -1
  68. package/dist/lib/dexie.js +15 -0
  69. package/dist/lib/env.js +1 -0
  70. package/dist/lib/index.js +19 -13
  71. package/dist/lib/loader.d.ts.map +1 -1
  72. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  73. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  74. package/dist/lib/migrate-sections-transform.js +7 -0
  75. package/dist/lib/registry.d.ts +39 -0
  76. package/dist/lib/registry.d.ts.map +1 -1
  77. package/dist/lib/registry.js +27 -0
  78. package/dist/media/index.js +1 -0
  79. package/dist/schemas/auth.js +1 -0
  80. package/dist/schemas/block.d.ts +20 -0
  81. package/dist/schemas/block.d.ts.map +1 -0
  82. package/dist/schemas/block.js +15 -0
  83. package/dist/schemas/index.js +13 -4
  84. package/dist/schemas/link.d.ts +7 -0
  85. package/dist/schemas/link.d.ts.map +1 -1
  86. package/dist/schemas/rich-text.d.ts +9 -0
  87. package/dist/schemas/rich-text.d.ts.map +1 -0
  88. package/dist/schemas/sections.d.ts +2 -0
  89. package/dist/schemas/sections.d.ts.map +1 -1
  90. package/dist/schemas/shared.d.ts +30 -0
  91. package/dist/schemas/shared.d.ts.map +1 -1
  92. package/dist/types/database.js +2 -0
  93. package/package.json +17 -1
  94. package/src/components/brandguide/Colors.tsx +35 -33
  95. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  96. package/src/components/editor/ColSpanControl.tsx +56 -0
  97. package/src/components/editor/SectionWrapper.tsx +44 -20
  98. package/src/components/editor/SettingsForm.tsx +100 -73
  99. package/src/components/primitives/EditableGrid.tsx +40 -36
  100. package/src/components/primitives/IconPicker.tsx +116 -26
  101. package/src/components/sections/Container/Container.tsx +354 -0
  102. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  103. package/src/components/sections/Container/index.tsx +51 -0
  104. package/src/components/sections/IconList/IconList.tsx +113 -43
  105. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  106. package/src/components/sections/IconList/index.tsx +1 -1
  107. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  108. package/src/components/sections/Media/index.tsx +85 -0
  109. package/src/components/sections/Prose/index.tsx +1 -0
  110. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  111. package/src/components/sections/Spacer/index.tsx +18 -0
  112. package/src/components/sections/all-sections.ts +10 -8
  113. package/src/components/sections/register-schemas.ts +5 -2
  114. package/src/components/shared/Tabs.tsx +63 -0
  115. package/src/components/shell/EditorShell.tsx +105 -13
  116. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  117. package/src/components/shell/blockMoveDispatch.ts +40 -0
  118. package/src/hooks/useBlockDnd.ts +144 -0
  119. package/src/lib/block-dnd.ts +58 -0
  120. package/src/lib/block-move.ts +236 -0
  121. package/src/lib/container-grid.ts +58 -0
  122. package/src/lib/container-ops.ts +159 -0
  123. package/src/lib/dexie.ts +22 -0
  124. package/src/lib/loader.ts +16 -4
  125. package/src/lib/migrate-sections-transform.ts +147 -0
  126. package/src/lib/registry.ts +48 -0
  127. package/src/schemas/block.ts +40 -0
  128. package/src/schemas/link.ts +19 -1
  129. package/src/schemas/rich-text.ts +11 -0
  130. package/src/schemas/sections.ts +5 -1
  131. package/src/schemas/shared.ts +6 -0
  132. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  133. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  134. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  135. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  136. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  137. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  138. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  139. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  140. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  141. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  142. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  143. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  144. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  145. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  146. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  147. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  148. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  149. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  150. package/src/components/brandguide/DoDontList.tsx +0 -67
  151. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  152. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  153. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  154. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  155. package/src/components/sections/DoDontList/index.tsx +0 -45
  156. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  157. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  158. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  159. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  160. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  161. package/src/components/sections/MediaGrid/index.tsx +0 -57
  162. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  163. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  164. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  165. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -0,0 +1,354 @@
1
+ import { type ComponentType, type ReactNode } from "react";
2
+ import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
3
+ import { getSection } from "../../../lib/registry";
4
+ import { cn } from "../../../lib/cn";
5
+ import { containerGridClass, colSpanClass, spacerHiddenClass } from "../../../lib/container-grid";
6
+ import { ChildBlockWrapper } from "../../editor/ChildBlockWrapper";
7
+ import { SettingsForm, type SettingsFormResult } from "../../editor/SettingsForm";
8
+ import { useBlockDnd, useBlockDropZone } from "../../../hooks/useBlockDnd";
9
+ import {
10
+ removeChildAt,
11
+ duplicateChildAt,
12
+ setChildColSpan,
13
+ isSpacer,
14
+ occupiedColumns,
15
+ trimTrailingSpacers,
16
+ } from "../../../lib/container-ops";
17
+ import type { Section } from "../../../schemas/sections";
18
+
19
+ // Stable allowedEdges arrays — must not be re-created per render (the dnd hooks
20
+ // re-subscribe whenever this array identity changes).
21
+ const ROW_EDGES: Edge[] = ["left", "right"];
22
+ const COL_EDGES: Edge[] = ["top", "bottom"];
23
+
24
+ type ChildComponent = ComponentType<{
25
+ content: Section;
26
+ options: Record<string, unknown>;
27
+ isEditMode: boolean;
28
+ onChange?: (content: unknown) => void;
29
+ openModal?: (title: string, content: ReactNode) => void;
30
+ }>;
31
+
32
+ interface ContainerChildProps {
33
+ containerId: string;
34
+ index: number;
35
+ child: Section;
36
+ columns: number;
37
+ flow: string;
38
+ cellClass: string | undefined;
39
+ label: string;
40
+ hasSettings: boolean;
41
+ mergedOptions: Record<string, unknown>;
42
+ isEditMode: boolean;
43
+ ChildComp: ChildComponent;
44
+ onDelete: () => void;
45
+ onDuplicate: () => void;
46
+ onColSpanChange: (span: number) => void;
47
+ onOpenSettings?: () => void;
48
+ onChildContentChange?: (content: unknown) => void;
49
+ openModal?: (title: string, content: ReactNode) => void;
50
+ }
51
+
52
+ /**
53
+ * One per child. Calls useBlockDnd (a hook) so it cannot live inside children.map().
54
+ * Makes the child cell both a drag source and a drop target advertising
55
+ * { containerId, index }; the central EditorContent monitor performs the actual move.
56
+ */
57
+ function ContainerChild({
58
+ containerId,
59
+ index,
60
+ child,
61
+ columns,
62
+ flow,
63
+ cellClass,
64
+ label,
65
+ hasSettings,
66
+ mergedOptions,
67
+ isEditMode,
68
+ ChildComp,
69
+ onDelete,
70
+ onDuplicate,
71
+ onColSpanChange,
72
+ onOpenSettings,
73
+ onChildContentChange,
74
+ openModal,
75
+ }: ContainerChildProps) {
76
+ const isContainer = Array.isArray((child.content as { children?: unknown })?.children);
77
+ const { dragRef, handleRef, closestEdge, isDragging } = useBlockDnd({
78
+ blockId: child.id ?? `child-${index}`,
79
+ containerId,
80
+ index,
81
+ isContainer,
82
+ allowedEdges: flow === "column" ? COL_EDGES : ROW_EDGES,
83
+ enabled: true,
84
+ });
85
+
86
+ return (
87
+ <div
88
+ ref={dragRef}
89
+ className={cellClass}
90
+ data-child-index={index}
91
+ data-child-id={child.id}
92
+ >
93
+ <ChildBlockWrapper
94
+ label={label}
95
+ columns={columns}
96
+ colSpan={Math.min(child.layout?.colSpan ?? 1, columns)}
97
+ closestEdge={closestEdge}
98
+ isDragging={isDragging}
99
+ hasSettings={hasSettings}
100
+ dragHandleRef={handleRef}
101
+ onDelete={onDelete}
102
+ onDuplicate={onDuplicate}
103
+ onColSpanChange={onColSpanChange}
104
+ onOpenSettings={onOpenSettings}
105
+ >
106
+ <ChildComp
107
+ content={child}
108
+ options={mergedOptions}
109
+ isEditMode={isEditMode}
110
+ onChange={onChildContentChange}
111
+ openModal={openModal}
112
+ />
113
+ </ChildBlockWrapper>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ interface ContainerContent {
119
+ columns: number;
120
+ flow: string;
121
+ childDefaults?: Record<string, unknown>;
122
+ children: Section[];
123
+ }
124
+
125
+ export interface ContainerProps {
126
+ content: { id?: string; content: ContainerContent };
127
+ isEditMode: boolean;
128
+ onChange?: (content: unknown) => void;
129
+ openModal?: (title: string, content: ReactNode) => void;
130
+ }
131
+
132
+ /**
133
+ * An EMPTY-COLUMN cell in edit mode — used for BOTH render-only trailing ghosts and persisted
134
+ * interior spacer children. They are visually identical (a spacer should just look like an empty
135
+ * column), so this is one component; only the `testId` differs. Each is a direct grid item that
136
+ * stretches to the row height (so it matches the size of neighbouring content cells) and advertises
137
+ * an absolute 1-based target column, so a drop pads the intervening tracks with spacers — or, when a
138
+ * spacer already holds that column, replaces it. There is no per-cell delete: an interior gap is
139
+ * closed by dropping/dragging a block onto it, or by deleting the block after it (trailing trim).
140
+ */
141
+ function EmptyColumnCell({
142
+ containerId,
143
+ targetColumn,
144
+ testId,
145
+ }: {
146
+ containerId: string;
147
+ targetColumn: number;
148
+ testId: string;
149
+ }) {
150
+ const { dropRef, closestEdge } = useBlockDropZone({
151
+ containerId,
152
+ index: 0,
153
+ toColumn: targetColumn,
154
+ allowedEdges: ROW_EDGES,
155
+ enabled: true,
156
+ });
157
+ const active = closestEdge !== null;
158
+ return (
159
+ <div
160
+ ref={dropRef}
161
+ data-empty-dropzone
162
+ data-testid={testId}
163
+ data-target-column={targetColumn}
164
+ className={cn(
165
+ "flex min-h-20 items-center justify-center rounded-md border-2 border-dashed text-xs text-base-contrast-light/50",
166
+ active ? "border-primary" : "border-base-200",
167
+ )}
168
+ >
169
+ Empty
170
+ </div>
171
+ );
172
+ }
173
+
174
+ function newId(): string {
175
+ return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
176
+ ? crypto.randomUUID()
177
+ : `child-${Math.random().toString(36).slice(2)}-${Date.now()}`;
178
+ }
179
+
180
+ export function Container({ content, isEditMode, onChange, openModal }: ContainerProps) {
181
+ const { columns, flow, childDefaults, children } = content.content;
182
+
183
+ const editable = isEditMode && !!onChange;
184
+
185
+ // container-ops operate on a proper Section whose content is the ContainerContent.
186
+ const container: Section = {
187
+ id: content.id ?? "container",
188
+ type: "container",
189
+ content: content.content as unknown as Record<string, unknown>,
190
+ };
191
+ const emit = (nextSection: Section) =>
192
+ onChange?.({ ...content, content: nextSection.content });
193
+
194
+ // Per-child inline content edits (e.g. prose/media bubble their own onChange).
195
+ const updateChild = (index: number, childContent: unknown) => {
196
+ const next = children.map((c, i) => (i === index ? (childContent as Section) : c));
197
+ onChange?.({ ...content, content: { ...content.content, children: next } });
198
+ };
199
+
200
+ // Fixed 32px gap on BOTH axes, in view AND edit. The gap is no longer user-
201
+ // configurable; 32px gives the left-rail child controls room while keeping the
202
+ // published grid identical to the editor (true WYSIWYG).
203
+ const gridClass = cn(
204
+ "grid",
205
+ "gap-8",
206
+ containerGridClass[columns] ?? "grid-cols-1",
207
+ flow === "column" && "grid-flow-col",
208
+ );
209
+
210
+ // --- View mode: zero-JS output, identical to the Plan 3 viewer. ---
211
+ if (!editable) {
212
+ return (
213
+ <div className="@container">
214
+ <div className={gridClass}>
215
+ {children.map((child, index) => {
216
+ const def = getSection(child.type);
217
+ if (!def) return null;
218
+ const Child = def.component;
219
+ const span = child.layout?.colSpan ?? 1;
220
+ const childOptions =
221
+ "options" in child ? (child.options as Record<string, unknown>) : undefined;
222
+ const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
223
+ const cellClass = cn(
224
+ colSpanClass[columns]?.[Math.min(span, columns)] || undefined,
225
+ child.type === "spacer" && spacerHiddenClass[columns],
226
+ );
227
+ return (
228
+ <div key={child.id ?? index} className={cellClass}>
229
+ <Child content={child} options={mergedOptions} isEditMode={isEditMode} />
230
+ </div>
231
+ );
232
+ })}
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+
238
+ // A child's 1-based start column (spacer = 1 track; real child = clamped colSpan).
239
+ function columnStartOf(index: number): number {
240
+ let c = 0;
241
+ for (let i = 0; i < index; i++) {
242
+ c += isSpacer(children[i]) ? 1 : Math.min(children[i].layout?.colSpan ?? 1, columns);
243
+ }
244
+ return c + 1;
245
+ }
246
+
247
+ // --- Edit-mode child rendering. Each child is wrapped in <ContainerChild> so it can
248
+ // call the useBlockDnd hook (rules of hooks → one component per child). ---
249
+ function renderEditChild(child: Section, index: number): ReactNode {
250
+ const def = getSection(child.type);
251
+ if (!def) return null;
252
+
253
+ if (isSpacer(child)) {
254
+ // A persisted spacer renders as a plain empty-column cell (a direct grid item, like a
255
+ // ghost) — no drag handle, no delete. It's a drop target that replaces itself when a block
256
+ // is dropped onto its column.
257
+ return (
258
+ <EmptyColumnCell
259
+ key={child.id ?? index}
260
+ containerId={container.id}
261
+ targetColumn={columnStartOf(index)}
262
+ testId="spacer-cell"
263
+ />
264
+ );
265
+ }
266
+
267
+ const span = child.layout?.colSpan ?? 1;
268
+ const childOptions = "options" in child ? (child.options as Record<string, unknown>) : undefined;
269
+ const mergedOptions = { ...(childDefaults ?? {}), ...(childOptions ?? {}) };
270
+ const cellClass = colSpanClass[columns]?.[Math.min(span, columns)] || undefined;
271
+
272
+ const hasSettings = !!(def.settings || def.settingsForm) && !!openModal;
273
+ const onOpenSettings =
274
+ hasSettings && openModal
275
+ ? () => {
276
+ const childOpts = childOptions ?? {};
277
+ const childContent = (child.content ?? {}) as Record<string, unknown>;
278
+ // Seed the form from merged content+options so content-target fields (e.g.
279
+ // media.link, button.link/download) round-trip instead of re-emitting at their
280
+ // default and clobbering the stored value. Mirrors EditorShell's top-level seed.
281
+ const seed = { ...childContent, ...childOpts };
282
+ const applyResult = (result: SettingsFormResult) =>
283
+ updateChild(index, {
284
+ ...child,
285
+ content: { ...(child.content as object), ...result.content },
286
+ options: { ...childOpts, ...result.options },
287
+ });
288
+ if (def.settingsForm) {
289
+ const CustomForm = def.settingsForm;
290
+ openModal(
291
+ `${def.label} Settings`,
292
+ <CustomForm {...seed} onChange={applyResult} />,
293
+ );
294
+ } else if (def.settings) {
295
+ openModal(
296
+ `${def.label} Settings`,
297
+ <SettingsForm
298
+ schema={def.settings}
299
+ values={seed}
300
+ onChange={applyResult}
301
+ tabs={def.settingsTabs}
302
+ />,
303
+ );
304
+ }
305
+ }
306
+ : undefined;
307
+
308
+ return (
309
+ <ContainerChild
310
+ key={child.id ?? index}
311
+ containerId={container.id}
312
+ index={index}
313
+ child={child}
314
+ columns={columns}
315
+ flow={flow}
316
+ cellClass={cellClass}
317
+ label={def.label}
318
+ hasSettings={hasSettings}
319
+ mergedOptions={mergedOptions}
320
+ isEditMode={isEditMode}
321
+ ChildComp={def.component as ChildComponent}
322
+ onDelete={() => emit(trimTrailingSpacers(removeChildAt(container, index).container))}
323
+ onDuplicate={() => emit(duplicateChildAt(container, index, newId))}
324
+ onColSpanChange={(s) => emit(setChildColSpan(container, index, s))}
325
+ onOpenSettings={onOpenSettings}
326
+ onChildContentChange={(c: unknown) => updateChild(index, c)}
327
+ openModal={openModal}
328
+ />
329
+ );
330
+ }
331
+
332
+ // --- Edit mode --- (insertion is surfaced via the section toolbar's "+" in
333
+ // EditorContent, not a Container-owned affordance.) Children render first, then a
334
+ // ghost drop cell for each empty trailing column. Each ghost advertises its absolute
335
+ // 1-based target column so a drop pads intervening tracks with spacers (the empty
336
+ // container is the all-columns-empty case, with ghosts for columns 1..columns).
337
+ const occupied = occupiedColumns(children, columns);
338
+ const emptyCols = occupied < columns ? columns - occupied : 0;
339
+ return (
340
+ <div className="@container">
341
+ <div className={gridClass}>
342
+ {children.map(renderEditChild)}
343
+ {Array.from({ length: emptyCols }, (_, i) => (
344
+ <EmptyColumnCell
345
+ key={`ghost-${i}`}
346
+ containerId={container.id}
347
+ targetColumn={occupied + i + 1}
348
+ testId="ghost-cell"
349
+ />
350
+ ))}
351
+ </div>
352
+ </div>
353
+ );
354
+ }
@@ -0,0 +1,113 @@
1
+ import { useState } from "react";
2
+ import { getSection } from "../../../lib/registry";
3
+ import { SettingsForm } from "../../editor/SettingsForm";
4
+ import { MAX_CONTAINER_COLUMNS } from "../../../lib/container-grid";
5
+ import type { SettingsSchema } from "../../../lib/registry";
6
+
7
+ interface ChildLike {
8
+ type: string;
9
+ options?: Record<string, unknown>;
10
+ }
11
+
12
+ interface ContainerSettingsFormProps {
13
+ columns?: number;
14
+ flow?: string;
15
+ childDefaults?: Record<string, unknown>;
16
+ children?: ChildLike[]; // the container's child blocks (DATA, not React nodes)
17
+ onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
18
+ }
19
+
20
+ /** Union of inheritable option fields across the child types currently present. */
21
+ function inheritableSchema(children: ChildLike[]): SettingsSchema {
22
+ const out: SettingsSchema = {};
23
+ for (const child of children) {
24
+ const def = getSection(child.type);
25
+ if (!def?.settings || !def.inheritableSettings) continue;
26
+ for (const key of def.inheritableSettings) {
27
+ const field = def.settings[key];
28
+ if (field && !out[key]) out[key] = field;
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function ContainerSettingsForm({
35
+ columns = 1,
36
+ flow = "row",
37
+ childDefaults = {},
38
+ children = [],
39
+ onChange,
40
+ }: ContainerSettingsFormProps) {
41
+ const [layout, setLayout] = useState({ columns, flow });
42
+ const [defaults, setDefaults] = useState<Record<string, unknown>>(childDefaults);
43
+
44
+ const childSchema = inheritableSchema(children);
45
+ const hasChildDefaults = Object.keys(childSchema).length > 0;
46
+
47
+ function emit(
48
+ nextLayout: { columns: number; flow: string },
49
+ nextDefaults: Record<string, unknown>,
50
+ ) {
51
+ onChange({ content: { ...nextLayout, childDefaults: nextDefaults }, options: {} });
52
+ }
53
+
54
+ const layoutSchema: SettingsSchema = {
55
+ columns: {
56
+ type: "select",
57
+ label: "Columns",
58
+ default: String(layout.columns),
59
+ target: "content",
60
+ coerce: "number",
61
+ options: Array.from({ length: MAX_CONTAINER_COLUMNS }, (_, i) => ({
62
+ label: String(i + 1),
63
+ value: String(i + 1),
64
+ })),
65
+ },
66
+ flow: {
67
+ type: "select",
68
+ label: "Flow",
69
+ default: layout.flow,
70
+ target: "content",
71
+ options: [
72
+ { label: "Rows (wrap across)", value: "row" },
73
+ { label: "Columns (fill down)", value: "column" },
74
+ ],
75
+ },
76
+ };
77
+
78
+ return (
79
+ <div className="flex flex-col gap-6">
80
+ <fieldset className="flex flex-col gap-4">
81
+ <legend className="mb-2 text-sm font-semibold text-base-contrast">Layout</legend>
82
+ <SettingsForm
83
+ schema={layoutSchema}
84
+ values={{ columns: String(layout.columns), flow: layout.flow }}
85
+ onChange={(result) => {
86
+ const next = {
87
+ columns: Number(result.content.columns ?? layout.columns),
88
+ flow: String(result.content.flow ?? layout.flow),
89
+ };
90
+ setLayout(next);
91
+ emit(next, defaults);
92
+ }}
93
+ />
94
+ </fieldset>
95
+
96
+ {hasChildDefaults && (
97
+ <fieldset className="flex flex-col gap-4 border-t border-base-200 pt-4">
98
+ <legend className="mb-2 text-sm font-semibold text-base-contrast">Apply to all items</legend>
99
+ <SettingsForm
100
+ schema={childSchema}
101
+ values={defaults}
102
+ onChange={(result) => {
103
+ // inheritable fields are options-typed in their source defs → result.options
104
+ const next = { ...defaults, ...result.options };
105
+ setDefaults(next);
106
+ emit(layout, next);
107
+ }}
108
+ />
109
+ </fieldset>
110
+ )}
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,51 @@
1
+ import { defineSection } from "../../../lib/registry";
2
+ import { z } from "zod";
3
+ import { LayoutTemplate } from "lucide-react";
4
+ import { getSectionSchema } from "../../../schemas/sections";
5
+ import { MAX_CONTAINER_COLUMNS } from "../../../lib/container-grid";
6
+ import { Container } from "./Container";
7
+ import { ContainerSettingsForm } from "./ContainerSettingsForm";
8
+
9
+ const schema = z.object({
10
+ type: z.literal("container"),
11
+ content: z
12
+ .object({
13
+ columns: z.number().int().min(1).max(MAX_CONTAINER_COLUMNS).default(1),
14
+ flow: z.enum(["row", "column"]).default("row"),
15
+ childDefaults: z.record(z.string(), z.unknown()).optional(),
16
+ // Recursive: children are full blocks. z.lazy defers evaluation to parse
17
+ // time, by which point every section schema (incl. container) is registered.
18
+ children: z.array(z.lazy(() => getSectionSchema())).default([]),
19
+ })
20
+ // Bound each child's colSpan to the available columns (spec §4.2). Non-rejecting
21
+ // and idempotent: a hand-edited/migrated file can't persist a phantom span, and
22
+ // a column-count reduction auto-clamps on the next save.
23
+ .transform((c) => ({
24
+ ...c,
25
+ children: c.children.map((child) =>
26
+ (child as { layout?: { colSpan?: number } }).layout?.colSpan &&
27
+ (child as { layout?: { colSpan?: number } }).layout!.colSpan! > c.columns
28
+ ? { ...child, layout: { ...(child as { layout?: { colSpan?: number } }).layout, colSpan: c.columns } }
29
+ : child,
30
+ ),
31
+ })),
32
+ });
33
+
34
+ export default defineSection({
35
+ type: "container",
36
+ label: "Container",
37
+ icon: <LayoutTemplate size={18} />,
38
+ schema,
39
+ component: ({ content, onChange, isEditMode, openModal }) => (
40
+ <Container content={content as never} isEditMode={!!isEditMode} onChange={onChange as never} openModal={openModal} />
41
+ ),
42
+ defaults: () => ({
43
+ type: "container" as const,
44
+ content: { columns: 1, flow: "row" as const, children: [] },
45
+ }),
46
+ getLabel: (content) => {
47
+ const n = content.content.children.length;
48
+ return `Container (${n} item${n === 1 ? "" : "s"})`;
49
+ },
50
+ settingsForm: ContainerSettingsForm,
51
+ });