@drawnagency/primitives 0.1.55 → 0.1.57

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 (173) hide show
  1. package/dist/auth/cookies.d.ts.map +1 -1
  2. package/dist/auth/index.js +1 -1
  3. package/dist/{chunk-24SUF2BC.js → chunk-ICLXLWQ5.js} +13 -74
  4. package/dist/chunk-NSCT3AMV.js +32 -0
  5. package/dist/{chunk-KDGYHU36.js → chunk-PRKUXM7E.js} +35 -10
  6. package/dist/{chunk-PUNXQK4M.js → chunk-PYWS3MOJ.js} +12 -2
  7. package/dist/chunk-TG43X7JO.js +123 -0
  8. package/dist/chunk-VKAGMEKE.js +90 -0
  9. package/dist/{chunk-B5VYSTPB.js → chunk-XTK4BR27.js} +1 -1
  10. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  11. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  12. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  13. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  14. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  15. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  16. package/dist/components/editor/SettingsForm.d.ts +5 -1
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  19. package/dist/components/primitives/IconPicker.d.ts +7 -1
  20. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  21. package/dist/components/sections/Container/Container.d.ts +20 -0
  22. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  23. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  24. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  25. package/dist/components/sections/Container/index.d.ts +11 -0
  26. package/dist/components/sections/Container/index.d.ts.map +1 -0
  27. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  28. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  29. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  30. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  31. package/dist/components/sections/IconList/index.d.ts +1 -0
  32. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  33. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  34. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  35. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  36. package/dist/components/sections/Media/index.d.ts.map +1 -0
  37. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  38. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  39. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  40. package/dist/components/sections/Spacer/index.d.ts +6 -0
  41. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  42. package/dist/components/sections/all-sections.d.ts +140 -0
  43. package/dist/components/sections/all-sections.d.ts.map +1 -0
  44. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  45. package/dist/components/sections/register.d.ts.map +1 -1
  46. package/dist/components/shared/Tabs.d.ts +24 -0
  47. package/dist/components/shared/Tabs.d.ts.map +1 -0
  48. package/dist/components/shell/EditorShell.d.ts +2 -1
  49. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  50. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  51. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  52. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  53. package/dist/hooks/useBlockDnd.d.ts +48 -0
  54. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  55. package/dist/hooks/useEditorPublish.d.ts +2 -1
  56. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  57. package/dist/index.js +69 -48
  58. package/dist/lib/block-dnd.d.ts +42 -0
  59. package/dist/lib/block-dnd.d.ts.map +1 -0
  60. package/dist/lib/block-move.d.ts +31 -0
  61. package/dist/lib/block-move.d.ts.map +1 -0
  62. package/dist/lib/container-grid.d.ts +29 -0
  63. package/dist/lib/container-grid.d.ts.map +1 -0
  64. package/dist/lib/container-ops.d.ts +44 -0
  65. package/dist/lib/container-ops.d.ts.map +1 -0
  66. package/dist/lib/dexie.d.ts +12 -1
  67. package/dist/lib/dexie.d.ts.map +1 -1
  68. package/dist/lib/dexie.js +28 -3
  69. package/dist/lib/index.js +10 -7
  70. package/dist/lib/loader.d.ts.map +1 -1
  71. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  72. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  73. package/dist/lib/migrate-sections-transform.js +6 -0
  74. package/dist/lib/registry.d.ts +39 -2
  75. package/dist/lib/registry.d.ts.map +1 -1
  76. package/dist/lib/registry.js +26 -0
  77. package/dist/lib/sanitize.d.ts.map +1 -1
  78. package/dist/schemas/block.d.ts +20 -0
  79. package/dist/schemas/block.d.ts.map +1 -0
  80. package/dist/schemas/block.js +14 -0
  81. package/dist/schemas/index.js +10 -2
  82. package/dist/schemas/link.d.ts +7 -0
  83. package/dist/schemas/link.d.ts.map +1 -1
  84. package/dist/schemas/rich-text.d.ts +9 -0
  85. package/dist/schemas/rich-text.d.ts.map +1 -0
  86. package/dist/schemas/sections.d.ts +2 -0
  87. package/dist/schemas/sections.d.ts.map +1 -1
  88. package/dist/schemas/shared.d.ts +31 -0
  89. package/dist/schemas/shared.d.ts.map +1 -1
  90. package/dist/storage/index.d.ts +1 -0
  91. package/dist/storage/index.d.ts.map +1 -1
  92. package/dist/storage/types.d.ts +13 -1
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/package.json +13 -1
  95. package/src/auth/cookies.ts +6 -1
  96. package/src/components/brandguide/Colors.tsx +35 -33
  97. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  98. package/src/components/editor/ColSpanControl.tsx +56 -0
  99. package/src/components/editor/SectionWrapper.tsx +44 -20
  100. package/src/components/editor/SettingsForm.tsx +100 -73
  101. package/src/components/primitives/EditableGrid.tsx +40 -36
  102. package/src/components/primitives/IconPicker.tsx +116 -26
  103. package/src/components/sections/Container/Container.tsx +354 -0
  104. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  105. package/src/components/sections/Container/index.tsx +51 -0
  106. package/src/components/sections/IconList/IconList.tsx +113 -43
  107. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  108. package/src/components/sections/IconList/index.tsx +1 -1
  109. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  110. package/src/components/sections/Media/index.tsx +85 -0
  111. package/src/components/sections/Prose/index.tsx +1 -0
  112. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  113. package/src/components/sections/Spacer/index.tsx +18 -0
  114. package/src/components/sections/all-sections.ts +40 -0
  115. package/src/components/sections/register-schemas.ts +13 -18
  116. package/src/components/sections/register.ts +3 -17
  117. package/src/components/shared/Tabs.tsx +63 -0
  118. package/src/components/shell/EditorShell.tsx +147 -18
  119. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  120. package/src/components/shell/blockMoveDispatch.ts +40 -0
  121. package/src/hooks/useBlockDnd.ts +144 -0
  122. package/src/hooks/useEditorPublish.ts +17 -4
  123. package/src/lib/block-dnd.ts +58 -0
  124. package/src/lib/block-move.ts +236 -0
  125. package/src/lib/container-grid.ts +58 -0
  126. package/src/lib/container-ops.ts +159 -0
  127. package/src/lib/dexie.ts +47 -0
  128. package/src/lib/loader.ts +16 -4
  129. package/src/lib/migrate-sections-transform.ts +147 -0
  130. package/src/lib/registry.ts +48 -2
  131. package/src/lib/sanitize.ts +22 -1
  132. package/src/schemas/block.ts +40 -0
  133. package/src/schemas/link.ts +19 -1
  134. package/src/schemas/rich-text.ts +11 -0
  135. package/src/schemas/sections.ts +5 -1
  136. package/src/schemas/shared.ts +16 -0
  137. package/src/schemas/site-config.ts +3 -3
  138. package/src/storage/index.ts +1 -0
  139. package/src/storage/types.ts +17 -0
  140. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  141. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  142. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  143. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  144. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  145. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  146. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  147. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  148. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  149. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  150. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  151. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  152. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  153. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  154. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  155. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  156. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  157. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  158. package/src/components/brandguide/DoDontList.tsx +0 -67
  159. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  160. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  161. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  162. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  163. package/src/components/sections/DoDontList/index.tsx +0 -45
  164. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  165. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  166. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  167. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  168. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  169. package/src/components/sections/MediaGrid/index.tsx +0 -57
  170. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  171. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  172. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  173. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -1,5 +1,5 @@
1
1
  import { useState, useRef, useEffect } from "react";
2
- import { ArrowRightLeft } from "lucide-react";
2
+ import { ArrowRightLeft, Plus } from "lucide-react";
3
3
  import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
4
4
  import { DragHandle } from "./DragHandle";
5
5
  import { InsertButton } from "./InsertButton";
@@ -11,6 +11,7 @@ import { SettingsForm } from "./SettingsForm";
11
11
  import { useEditorContext } from "../shell/EditorContext";
12
12
  import { useEditorModal } from "../shell/EditorModalContext";
13
13
  import type { WrapperProps } from "../../lib/registry";
14
+ import { buildBlockDragData, buildBlockDropData, isBlockDragData, canDropBlock } from "../../lib/block-dnd";
14
15
  import { cn } from "../../lib/cn";
15
16
 
16
17
  export function SectionWrapper({
@@ -20,6 +21,8 @@ export function SectionWrapper({
20
21
  dirty,
21
22
  index,
22
23
  isLast,
24
+ containerId,
25
+ isContainerBlock,
23
26
  definition,
24
27
  options,
25
28
  audiences,
@@ -31,6 +34,7 @@ export function SectionWrapper({
31
34
  onRequestInsert,
32
35
  onDelete,
33
36
  onMoveSection,
37
+ onAddChild,
34
38
  mainStatus,
35
39
  contentDiffersFromMain,
36
40
  isLocalOnly,
@@ -77,7 +81,7 @@ export function SectionWrapper({
77
81
  useEffect(() => {
78
82
  const block = blockRef.current;
79
83
  const handle = handleRef.current;
80
- if (!block || !handle || !onReorder) return;
84
+ if (!block || !handle || !isEditMode) return;
81
85
 
82
86
  let cleanup: (() => void) | undefined;
83
87
  let cancelled = false;
@@ -96,7 +100,13 @@ export function SectionWrapper({
96
100
  const cleanupDraggable = draggable({
97
101
  element: dropElement,
98
102
  dragHandle: handle,
99
- getInitialData: () => ({ dragType: "section", sectionId, index }),
103
+ getInitialData: () =>
104
+ buildBlockDragData({
105
+ blockId: sectionId,
106
+ containerId,
107
+ index,
108
+ isContainer: isContainerBlock,
109
+ }),
100
110
  onGenerateDragPreview: () => {
101
111
  dropElement.style.opacity = "0.4";
102
112
  requestAnimationFrame(() => {
@@ -115,31 +125,32 @@ export function SectionWrapper({
115
125
 
116
126
  const cleanupDropTarget = dropTargetForElements({
117
127
  element: dropElement,
118
- canDrop: ({ source }) => source.data.dragType === "section",
128
+ canDrop: ({ source }) =>
129
+ isBlockDragData(source.data) && canDropBlock(source.data, containerId),
119
130
  getData: ({ input, element }) =>
120
131
  attachClosestEdge(
121
- { sectionId, index },
132
+ buildBlockDropData({ dropContainerId: containerId, index }),
122
133
  { input, element, allowedEdges: ["top", "bottom"] },
123
134
  ),
124
- onDragEnter: ({ self }) => {
125
- setClosestEdge(extractClosestEdge(self.data));
126
- },
127
- onDrag: ({ self }) => {
135
+ onDrag: ({ self, source, location }) => {
136
+ const innermost = location.current.dropTargets[0]?.element === self.element;
137
+ if (
138
+ !innermost ||
139
+ !isBlockDragData(source.data) ||
140
+ source.data.blockId === sectionId
141
+ ) {
142
+ setClosestEdge(null);
143
+ return;
144
+ }
128
145
  setClosestEdge(extractClosestEdge(self.data));
129
146
  },
130
147
  onDragLeave: () => {
131
148
  setClosestEdge(null);
132
149
  },
133
- onDrop: ({ source, self }) => {
150
+ onDrop: () => {
151
+ // Central monitor (EditorContent) dispatches the move; we only
152
+ // clear local hover state here.
134
153
  setClosestEdge(null);
135
- const fromIndex = source.data.index as number;
136
- const edge = extractClosestEdge(self.data);
137
- let toIndex = index;
138
- if (edge === "bottom") toIndex = index + 1;
139
- if (fromIndex < toIndex) toIndex--;
140
- if (fromIndex !== toIndex) {
141
- onReorder(fromIndex, toIndex);
142
- }
143
154
  },
144
155
  });
145
156
 
@@ -153,7 +164,7 @@ export function SectionWrapper({
153
164
  cancelled = true;
154
165
  cleanup?.();
155
166
  };
156
- }, [sectionId, index, onReorder]);
167
+ }, [sectionId, index, containerId, isContainerBlock, isEditMode]);
157
168
 
158
169
  function handleSettingsClick() {
159
170
  if (!onSectionChange) return;
@@ -164,7 +175,8 @@ export function SectionWrapper({
164
175
  `${definition.label} Settings`,
165
176
  <CustomForm
166
177
  {...(options ?? {})}
167
- onChange={(values: Record<string, unknown>) => onSectionChange({ content: {}, options: values })}
178
+ onChange={(result: { content: Record<string, unknown>; options: Record<string, unknown> }) =>
179
+ onSectionChange(result as unknown as Record<string, unknown>)}
168
180
  />,
169
181
  );
170
182
  } else if (definition.settings) {
@@ -174,6 +186,7 @@ export function SectionWrapper({
174
186
  schema={definition.settings}
175
187
  values={options ?? {}}
176
188
  onChange={(result) => onSectionChange(result as unknown as Record<string, unknown>)}
189
+ tabs={definition.settingsTabs}
177
190
  />,
178
191
  );
179
192
  }
@@ -284,6 +297,17 @@ export function SectionWrapper({
284
297
  />
285
298
  </div>
286
299
 
300
+ {onAddChild && (
301
+ <button
302
+ type="button"
303
+ onClick={onAddChild}
304
+ aria-label="Add block"
305
+ className="pointer-events-auto cursor-pointer rounded p-1 text-base-contrast-light hover:text-primary"
306
+ >
307
+ <Plus size={16} />
308
+ </button>
309
+ )}
310
+
287
311
  {hasSettings && (
288
312
  <SettingsButton onClick={handleSettingsClick} />
289
313
  )}
@@ -5,6 +5,7 @@ import { Select } from "../shared/Select";
5
5
  import { Checkbox } from "../shared/Checkbox";
6
6
  import { FormLabel } from "../shared/FormLabel";
7
7
  import { LinkField } from "../shared/LinkField";
8
+ import { Tabs } from "../shared/Tabs";
8
9
  import type { LinkValue } from "../../schemas/link";
9
10
  import { cn } from "../../lib/cn";
10
11
 
@@ -17,6 +18,7 @@ interface SettingsFormProps {
17
18
  schema: SettingsSchema;
18
19
  values: Record<string, unknown>;
19
20
  onChange: (result: SettingsFormResult) => void;
21
+ tabs?: { label: string; fields: string[] }[];
20
22
  }
21
23
 
22
24
  function resolveValues(
@@ -37,6 +39,13 @@ function coerceValue(field: SettingsFieldDef, value: unknown): unknown {
37
39
  return value;
38
40
  }
39
41
 
42
+ function normalizeEmpty(field: SettingsFieldDef, value: unknown): unknown {
43
+ if (field.type === "select" && "emptyIsUndefined" in field && field.emptyIsUndefined && value === "") {
44
+ return undefined;
45
+ }
46
+ return value;
47
+ }
48
+
40
49
  function splitByTarget(
41
50
  schema: SettingsSchema,
42
51
  allValues: Record<string, unknown>,
@@ -44,7 +53,7 @@ function splitByTarget(
44
53
  const content: Record<string, unknown> = {};
45
54
  const options: Record<string, unknown> = {};
46
55
  for (const [key, field] of Object.entries(schema)) {
47
- const value = coerceValue(field, allValues[key]);
56
+ const value = normalizeEmpty(field, coerceValue(field, allValues[key]));
48
57
  const target = field.target ?? "options";
49
58
  if (target === "content") {
50
59
  content[key] = value;
@@ -84,7 +93,82 @@ function RangeField({
84
93
  );
85
94
  }
86
95
 
87
- export function SettingsForm({ schema, values, onChange }: SettingsFormProps) {
96
+ function renderField(
97
+ key: string,
98
+ field: SettingsFieldDef,
99
+ value: unknown,
100
+ handleFieldChange: (key: string, newValue: unknown) => void,
101
+ ) {
102
+ switch (field.type) {
103
+ case "text":
104
+ return (
105
+ <Input
106
+ key={key}
107
+ label={field.label}
108
+ value={String(value ?? "")}
109
+ placeholder={field.placeholder}
110
+ onChange={(v) => handleFieldChange(key, v)}
111
+ />
112
+ );
113
+
114
+ case "number":
115
+ return (
116
+ <Input
117
+ key={key}
118
+ label={field.label}
119
+ type="number"
120
+ value={String(value ?? "")}
121
+ onChange={(v) => handleFieldChange(key, v === "" ? "" : Number(v))}
122
+ />
123
+ );
124
+
125
+ case "checkbox":
126
+ return (
127
+ <Checkbox
128
+ key={key}
129
+ label={field.label}
130
+ checked={Boolean(value)}
131
+ onChange={(checked) => handleFieldChange(key, checked)}
132
+ />
133
+ );
134
+
135
+ case "select":
136
+ return (
137
+ <Select
138
+ key={key}
139
+ label={field.label}
140
+ value={String(value ?? "")}
141
+ options={field.options}
142
+ onChange={(v) => handleFieldChange(key, v)}
143
+ />
144
+ );
145
+
146
+ case "range":
147
+ return (
148
+ <RangeField
149
+ key={key}
150
+ field={field}
151
+ value={Number(value ?? field.min)}
152
+ onChange={(v) => handleFieldChange(key, v)}
153
+ />
154
+ );
155
+
156
+ case "link":
157
+ return (
158
+ <LinkField
159
+ key={key}
160
+ label={field.label}
161
+ value={(value as LinkValue) ?? field.default}
162
+ onChange={(v) => handleFieldChange(key, v)}
163
+ />
164
+ );
165
+
166
+ default:
167
+ return null;
168
+ }
169
+ }
170
+
171
+ export function SettingsForm({ schema, values, onChange, tabs }: SettingsFormProps) {
88
172
  const [local, setLocal] = useState(() => resolveValues(schema, values));
89
173
 
90
174
  function handleFieldChange(key: string, newValue: unknown) {
@@ -93,79 +177,22 @@ export function SettingsForm({ schema, values, onChange }: SettingsFormProps) {
93
177
  onChange(splitByTarget(schema, next));
94
178
  }
95
179
 
180
+ if (tabs) {
181
+ const fields = (keys: string[]) =>
182
+ keys.filter((key) => key in schema).map((key) => renderField(key, schema[key], local[key], handleFieldChange));
183
+
184
+ const listed = new Set(tabs.flatMap((t) => t.fields));
185
+ const otherKeys = Object.keys(schema).filter((key) => !listed.has(key));
186
+ const tabItems = [
187
+ ...tabs.map((t) => ({ id: t.label, label: t.label, content: <div className="flex flex-col gap-4">{fields(t.fields)}</div> })),
188
+ ...(otherKeys.length > 0 ? [{ id: "__other__", label: "Other", content: <div className="flex flex-col gap-4">{fields(otherKeys)}</div> }] : []),
189
+ ];
190
+ return <Tabs tabs={tabItems} fullBleedTabBar />;
191
+ }
192
+
96
193
  return (
97
194
  <div className="flex flex-col gap-4">
98
- {Object.entries(schema).map(([key, field]) => {
99
- const value = local[key];
100
-
101
- switch (field.type) {
102
- case "text":
103
- return (
104
- <Input
105
- key={key}
106
- label={field.label}
107
- value={String(value ?? "")}
108
- placeholder={field.placeholder}
109
- onChange={(v) => handleFieldChange(key, v)}
110
- />
111
- );
112
-
113
- case "number":
114
- return (
115
- <Input
116
- key={key}
117
- label={field.label}
118
- type="number"
119
- value={String(value ?? "")}
120
- onChange={(v) => handleFieldChange(key, v === "" ? "" : Number(v))}
121
- />
122
- );
123
-
124
- case "checkbox":
125
- return (
126
- <Checkbox
127
- key={key}
128
- label={field.label}
129
- checked={Boolean(value)}
130
- onChange={(checked) => handleFieldChange(key, checked)}
131
- />
132
- );
133
-
134
- case "select":
135
- return (
136
- <Select
137
- key={key}
138
- label={field.label}
139
- value={String(value ?? "")}
140
- options={field.options}
141
- onChange={(v) => handleFieldChange(key, v)}
142
- />
143
- );
144
-
145
- case "range":
146
- return (
147
- <RangeField
148
- key={key}
149
- field={field}
150
- value={Number(value ?? field.min)}
151
- onChange={(v) => handleFieldChange(key, v)}
152
- />
153
- );
154
-
155
- case "link":
156
- return (
157
- <LinkField
158
- key={key}
159
- label={field.label}
160
- value={(value as LinkValue) ?? field.default}
161
- onChange={(v) => handleFieldChange(key, v)}
162
- />
163
- );
164
-
165
- default:
166
- return null;
167
- }
168
- })}
195
+ {Object.entries(schema).map(([key, field]) => renderField(key, field, local[key], handleFieldChange))}
169
196
  </div>
170
197
  );
171
198
  }
@@ -3,7 +3,7 @@ import { ImageIcon } from "lucide-react";
3
3
  import { AddIcon, DragHandle, DeleteIcon, SettingsIcon } from "../shared/icons";
4
4
  import { IconButton } from "../shared/IconButton";
5
5
  import { cn } from "../../lib/cn";
6
- import { gridColsClass } from "../../lib/grid";
6
+ import { containerGridClass } from "../../lib/container-grid";
7
7
  import { useEditableCollection } from "./useEditableCollection";
8
8
 
9
9
  interface EditableGridProps<T> {
@@ -68,47 +68,51 @@ export function EditableGrid<T>({
68
68
 
69
69
  if (!isEditMode) {
70
70
  return (
71
- <div className={cn("grid gap-4", gridColsClass[columns] || "grid-cols-1", className)}>
72
- {wrappedItems.map((wrapped, index) => (
73
- <div key={wrapped.id}>
74
- {renderItem(wrapped.data, { isEditMode: false, index })}
75
- </div>
76
- ))}
71
+ <div className="@container">
72
+ <div className={cn("grid gap-4", containerGridClass[columns] || containerGridClass[1], className)}>
73
+ {wrappedItems.map((wrapped, index) => (
74
+ <div key={wrapped.id}>
75
+ {renderItem(wrapped.data, { isEditMode: false, index })}
76
+ </div>
77
+ ))}
78
+ </div>
77
79
  </div>
78
80
  );
79
81
  }
80
82
 
81
83
  return (
82
- <div className={cn("group/grid relative grid gap-4", gridColsClass[columns] || "grid-cols-1", className)}>
83
- {wrappedItems.map((wrapped, index) => (
84
- <GridCell
85
- key={wrapped.id}
86
- id={wrapped.id}
87
- index={index}
88
- isLast={index === wrappedItems.length - 1}
89
- isDragging={isDragging}
90
- onReorder={onReorder}
91
- onRemove={onRemove}
92
- onInsert={onInsert}
93
- onSettings={onItemSettings ? () => onItemSettings(index) : undefined}
94
- onImageClick={onItemImageClick ? () => onItemImageClick(index) : undefined}
95
- chromeTopClass={chromeTopClass}
96
- dragState={dragState}
97
- setDragState={setDragState}
98
- >
99
- {renderItem(wrapped.data, { isEditMode: true, index })}
100
- </GridCell>
101
- ))}
84
+ <div className="@container">
85
+ <div className={cn("group/grid relative grid gap-4", containerGridClass[columns] || containerGridClass[1], className)}>
86
+ {wrappedItems.map((wrapped, index) => (
87
+ <GridCell
88
+ key={wrapped.id}
89
+ id={wrapped.id}
90
+ index={index}
91
+ isLast={index === wrappedItems.length - 1}
92
+ isDragging={isDragging}
93
+ onReorder={onReorder}
94
+ onRemove={onRemove}
95
+ onInsert={onInsert}
96
+ onSettings={onItemSettings ? () => onItemSettings(index) : undefined}
97
+ onImageClick={onItemImageClick ? () => onItemImageClick(index) : undefined}
98
+ chromeTopClass={chromeTopClass}
99
+ dragState={dragState}
100
+ setDragState={setDragState}
101
+ >
102
+ {renderItem(wrapped.data, { isEditMode: true, index })}
103
+ </GridCell>
104
+ ))}
102
105
 
103
- {/* Add button — absolute, centered below the grid */}
104
- <IconButton
105
- icon={<AddIcon size={16} />}
106
- label="Add item"
107
- size="lg"
108
- intent="primary"
109
- onClick={onAdd}
110
- className="absolute -bottom-6 left-1/2 z-20 -translate-x-1/2 rounded-full border border-base-200 bg-base opacity-0 transition-opacity group-hover/grid:opacity-100"
111
- />
106
+ {/* Add button — absolute, centered below the grid */}
107
+ <IconButton
108
+ icon={<AddIcon size={16} />}
109
+ label="Add item"
110
+ size="lg"
111
+ intent="primary"
112
+ onClick={onAdd}
113
+ className="absolute -bottom-6 left-1/2 z-20 -translate-x-1/2 rounded-full border border-base-200 bg-base opacity-0 transition-opacity group-hover/grid:opacity-100"
114
+ />
115
+ </div>
112
116
  </div>
113
117
  );
114
118
  }
@@ -1,4 +1,5 @@
1
- import { useEffect, useRef } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Check, X } from "lucide-react";
2
3
  import { cn } from "../../lib/cn";
3
4
  import { curatedIcons } from "../../lib/icons";
4
5
 
@@ -7,10 +8,26 @@ interface IconPickerProps {
7
8
  onSelect: (iconId: string | null) => void;
8
9
  onClose: () => void;
9
10
  showRemove?: boolean;
11
+ /** Opt-in: render the None / Do-Don't mode toggle. Default false → grid-only. */
12
+ allowDoDont?: boolean;
13
+ /** Current per-item Do/Don't tag (only meaningful when allowDoDont). */
14
+ dodont?: "do" | "dont";
15
+ /** Commit a Do/Don't choice; pass undefined to clear the tag (None). */
16
+ onSelectDoDont?: (v: "do" | "dont" | undefined) => void;
10
17
  }
11
18
 
12
- export function IconPicker({ selected, onSelect, onClose, showRemove = true }: IconPickerProps) {
19
+ export function IconPicker({
20
+ selected,
21
+ onSelect,
22
+ onClose,
23
+ showRemove = true,
24
+ allowDoDont = false,
25
+ dodont,
26
+ onSelectDoDont,
27
+ }: IconPickerProps) {
13
28
  const panelRef = useRef<HTMLDivElement>(null);
29
+ // Open in the mode matching the item's current state.
30
+ const [mode, setMode] = useState<"none" | "dodont">(dodont ? "dodont" : "none");
14
31
 
15
32
  useEffect(() => {
16
33
  function handleMouseDown(e: MouseEvent) {
@@ -22,38 +39,111 @@ export function IconPicker({ selected, onSelect, onClose, showRemove = true }: I
22
39
  return () => document.removeEventListener("mousedown", handleMouseDown);
23
40
  }, [onClose]);
24
41
 
42
+ const showDoDont = allowDoDont && mode === "dodont";
43
+
25
44
  return (
26
45
  <div
27
46
  ref={panelRef}
28
47
  className="absolute z-50 w-56 rounded-lg border border-base-200 bg-base p-2 shadow-lg"
29
48
  >
30
- <div className="grid grid-cols-5 gap-1">
31
- {curatedIcons.map((entry) => {
32
- const Icon = entry.icon;
33
- const isSelected = selected === entry.id;
34
- return (
49
+ {allowDoDont && (
50
+ <div role="tablist" aria-label="Icon mode" className="mb-2 flex gap-1 rounded-md bg-base-200 p-0.5">
51
+ <button
52
+ type="button"
53
+ role="tab"
54
+ aria-label="None"
55
+ aria-selected={mode === "none"}
56
+ className={cn(
57
+ "cursor-pointer flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
58
+ mode === "none"
59
+ ? "bg-base text-base-contrast shadow-sm"
60
+ : "text-base-contrast-light hover:text-base-contrast",
61
+ )}
62
+ onClick={() => {
63
+ setMode("none");
64
+ onSelectDoDont?.(undefined);
65
+ }}
66
+ >
67
+ None
68
+ </button>
69
+ <button
70
+ type="button"
71
+ role="tab"
72
+ aria-label="Do / Don't"
73
+ aria-selected={mode === "dodont"}
74
+ className={cn(
75
+ "cursor-pointer flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
76
+ mode === "dodont"
77
+ ? "bg-base text-base-contrast shadow-sm"
78
+ : "text-base-contrast-light hover:text-base-contrast",
79
+ )}
80
+ onClick={() => setMode("dodont")}
81
+ >
82
+ Do / Don't
83
+ </button>
84
+ </div>
85
+ )}
86
+
87
+ {showDoDont ? (
88
+ <div className="grid grid-cols-2 gap-2">
89
+ <button
90
+ type="button"
91
+ aria-label="Do"
92
+ className={cn(
93
+ "cursor-pointer flex flex-col items-center justify-center gap-1 rounded py-2 transition-colors",
94
+ "text-green-600 hover:bg-base-accent",
95
+ dodont === "do" && "ring-2 ring-primary bg-base-accent",
96
+ )}
97
+ onClick={() => onSelectDoDont?.("do")}
98
+ >
99
+ <Check size={20} />
100
+ <span className="text-xs font-medium">Do</span>
101
+ </button>
102
+ <button
103
+ type="button"
104
+ aria-label="Don't"
105
+ className={cn(
106
+ "cursor-pointer flex flex-col items-center justify-center gap-1 rounded py-2 transition-colors",
107
+ "text-red-600 hover:bg-base-accent",
108
+ dodont === "dont" && "ring-2 ring-primary bg-base-accent",
109
+ )}
110
+ onClick={() => onSelectDoDont?.("dont")}
111
+ >
112
+ <X size={20} />
113
+ <span className="text-xs font-medium">Don't</span>
114
+ </button>
115
+ </div>
116
+ ) : (
117
+ <>
118
+ <div className="grid grid-cols-5 gap-1">
119
+ {curatedIcons.map((entry) => {
120
+ const Icon = entry.icon;
121
+ const isSelected = selected === entry.id;
122
+ return (
123
+ <button
124
+ key={entry.id}
125
+ aria-label={entry.label}
126
+ className={cn(
127
+ "cursor-pointer flex h-9 w-9 items-center justify-center rounded transition-colors",
128
+ "text-base-contrast-light hover:bg-base-accent hover:text-base-contrast",
129
+ isSelected && "ring-2 ring-primary bg-base-accent text-primary",
130
+ )}
131
+ onClick={() => onSelect(entry.id)}
132
+ >
133
+ <Icon size={18} />
134
+ </button>
135
+ );
136
+ })}
137
+ </div>
138
+ {showRemove && (
35
139
  <button
36
- key={entry.id}
37
- aria-label={entry.label}
38
- className={cn(
39
- "cursor-pointer flex h-9 w-9 items-center justify-center rounded transition-colors",
40
- "text-base-contrast-light hover:bg-base-accent hover:text-base-contrast",
41
- isSelected && "ring-2 ring-primary bg-base-accent text-primary",
42
- )}
43
- onClick={() => onSelect(entry.id)}
140
+ className="cursor-pointer mt-2 w-full rounded px-2 py-1.5 text-center text-xs text-base-contrast-light hover:bg-base-accent hover:text-base-contrast"
141
+ onClick={() => onSelect(null)}
44
142
  >
45
- <Icon size={18} />
143
+ Remove icon
46
144
  </button>
47
- );
48
- })}
49
- </div>
50
- {showRemove && (
51
- <button
52
- className="cursor-pointer mt-2 w-full rounded px-2 py-1.5 text-center text-xs text-base-contrast-light hover:bg-base-accent hover:text-base-contrast"
53
- onClick={() => onSelect(null)}
54
- >
55
- Remove icon
56
- </button>
145
+ )}
146
+ </>
57
147
  )}
58
148
  </div>
59
149
  );