nebula-cms 0.1.0

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 (241) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.mcp.json +12 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc.cjs +22 -0
  6. package/AGENTS.md +183 -0
  7. package/LICENSE +201 -0
  8. package/README.md +128 -0
  9. package/package.json +74 -0
  10. package/playground/.claude/settings.local.json +5 -0
  11. package/playground/astro.config.mjs +7 -0
  12. package/playground/node_modules/.bin/astro +21 -0
  13. package/playground/node_modules/.bin/rollup +21 -0
  14. package/playground/node_modules/.bin/tsc +21 -0
  15. package/playground/node_modules/.bin/tsserver +21 -0
  16. package/playground/node_modules/.bin/vite +21 -0
  17. package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
  18. package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
  19. package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
  20. package/playground/node_modules/.vite/deps/_metadata.json +184 -0
  21. package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
  22. package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
  23. package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
  24. package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
  25. package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
  26. package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
  27. package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
  28. package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
  29. package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
  30. package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
  31. package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
  32. package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
  33. package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
  34. package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
  35. package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
  36. package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
  37. package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
  38. package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
  39. package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
  40. package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
  41. package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
  42. package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
  43. package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
  44. package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
  45. package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
  46. package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
  47. package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
  48. package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
  49. package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
  50. package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
  51. package/playground/node_modules/.vite/deps/package.json +3 -0
  52. package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
  53. package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
  54. package/playground/node_modules/.vite/deps/svelte.js +55 -0
  55. package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
  56. package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
  57. package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
  58. package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
  59. package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
  60. package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
  61. package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
  62. package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
  63. package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
  64. package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
  65. package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
  66. package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
  67. package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
  68. package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
  69. package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
  70. package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
  71. package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
  72. package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
  73. package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
  74. package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
  75. package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
  76. package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
  77. package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
  78. package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
  79. package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
  80. package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
  81. package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
  82. package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
  83. package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
  84. package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
  85. package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
  86. package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
  87. package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
  88. package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
  89. package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
  90. package/playground/package.json +16 -0
  91. package/playground/pnpm-lock.yaml +3167 -0
  92. package/playground/src/content/authors/jane-doe.json +8 -0
  93. package/playground/src/content/config/build.toml +2 -0
  94. package/playground/src/content/courses/web-fundamentals.json +29 -0
  95. package/playground/src/content/docs/advanced.mdx +6 -0
  96. package/playground/src/content/docs/intro.md +6 -0
  97. package/playground/src/content/guides/getting-started.mdx +6 -0
  98. package/playground/src/content/posts/hello-world.md +7 -0
  99. package/playground/src/content/products/t-shirt.json +16 -0
  100. package/playground/src/content/recipes/pancakes.mdoc +8 -0
  101. package/playground/src/content/settings/site.yml +2 -0
  102. package/playground/src/content.config.ts +198 -0
  103. package/playground/src/env.d.ts +1 -0
  104. package/playground/src/pages/index.astro +11 -0
  105. package/playground/src/pages/nebula.astro +14 -0
  106. package/pnpm-workspace.yaml +2 -0
  107. package/scripts/subset-icons.mjs +178 -0
  108. package/src/astro/index.ts +295 -0
  109. package/src/client/Admin.svelte +283 -0
  110. package/src/client/components/BackendPicker.svelte +291 -0
  111. package/src/client/components/DraftChip.svelte +46 -0
  112. package/src/client/components/MetadataForm.svelte +56 -0
  113. package/src/client/components/ThemeToggle.svelte +18 -0
  114. package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
  115. package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
  116. package/src/client/components/editor/EditorPane.svelte +227 -0
  117. package/src/client/components/editor/EditorTabs.svelte +81 -0
  118. package/src/client/components/editor/EditorToolbar.svelte +131 -0
  119. package/src/client/components/editor/FormatSelector.svelte +66 -0
  120. package/src/client/components/editor/Toolbar.svelte +17 -0
  121. package/src/client/components/fields/ArrayField.svelte +339 -0
  122. package/src/client/components/fields/ArrayItem.svelte +325 -0
  123. package/src/client/components/fields/BooleanField.svelte +114 -0
  124. package/src/client/components/fields/DateField.svelte +82 -0
  125. package/src/client/components/fields/EnumField.svelte +74 -0
  126. package/src/client/components/fields/FieldWrapper.svelte +96 -0
  127. package/src/client/components/fields/NumberField.svelte +99 -0
  128. package/src/client/components/fields/ObjectField.svelte +121 -0
  129. package/src/client/components/fields/SchemaField.svelte +107 -0
  130. package/src/client/components/fields/StringField.svelte +104 -0
  131. package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
  132. package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
  133. package/src/client/css/a11y.css +14 -0
  134. package/src/client/css/btn.css +113 -0
  135. package/src/client/css/dialog.css +29 -0
  136. package/src/client/css/field-input.css +39 -0
  137. package/src/client/css/reset.css +59 -0
  138. package/src/client/css/theme.css +77 -0
  139. package/src/client/index.ts +1 -0
  140. package/src/client/js/drafts/merge.svelte.ts +121 -0
  141. package/src/client/js/drafts/ops.svelte.ts +227 -0
  142. package/src/client/js/drafts/storage.ts +108 -0
  143. package/src/client/js/drafts/workers/diff.ts +40 -0
  144. package/src/client/js/editor/editor.svelte.ts +343 -0
  145. package/src/client/js/editor/languages.ts +98 -0
  146. package/src/client/js/editor/link-wrap.ts +45 -0
  147. package/src/client/js/editor/markdown-shortcuts.ts +261 -0
  148. package/src/client/js/handlers/admin.ts +246 -0
  149. package/src/client/js/state/dialogs.svelte.ts +35 -0
  150. package/src/client/js/state/router.svelte.ts +156 -0
  151. package/src/client/js/state/schema.svelte.ts +140 -0
  152. package/src/client/js/state/state.svelte.ts +334 -0
  153. package/src/client/js/state/theme.svelte.ts +173 -0
  154. package/src/client/js/storage/adapter.ts +102 -0
  155. package/src/client/js/storage/client.ts +150 -0
  156. package/src/client/js/storage/db.ts +36 -0
  157. package/src/client/js/storage/fsa.ts +110 -0
  158. package/src/client/js/storage/github.ts +297 -0
  159. package/src/client/js/storage/storage.ts +83 -0
  160. package/src/client/js/storage/workers/frontmatter.ts +320 -0
  161. package/src/client/js/storage/workers/storage.ts +177 -0
  162. package/src/client/js/storage/workers/toml-parser.ts +106 -0
  163. package/src/client/js/storage/workers/yaml-parser.ts +132 -0
  164. package/src/client/js/utils/file-types.ts +192 -0
  165. package/src/client/js/utils/format.ts +16 -0
  166. package/src/client/js/utils/frontmatter.ts +38 -0
  167. package/src/client/js/utils/schema-utils.ts +295 -0
  168. package/src/client/js/utils/slug.ts +18 -0
  169. package/src/client/js/utils/sort.ts +84 -0
  170. package/src/client/js/utils/stable-stringify.ts +27 -0
  171. package/src/client/js/utils/url-utils.ts +38 -0
  172. package/src/types.ts +25 -0
  173. package/src/virtual.d.ts +22 -0
  174. package/svelte.config.js +4 -0
  175. package/tests/astro/build.test.ts +63 -0
  176. package/tests/astro/index.test.ts +689 -0
  177. package/tests/client/components/Admin.test.ts +446 -0
  178. package/tests/client/components/BackendPicker.test.ts +239 -0
  179. package/tests/client/components/DraftChip.test.ts +53 -0
  180. package/tests/client/components/MetadataForm.test.ts +164 -0
  181. package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
  182. package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
  183. package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
  184. package/tests/client/components/editor/EditorPane.test.ts +100 -0
  185. package/tests/client/components/editor/EditorTabs.test.ts +253 -0
  186. package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
  187. package/tests/client/components/editor/fixtures.ts +31 -0
  188. package/tests/client/components/fields/ArrayField.test.ts +197 -0
  189. package/tests/client/components/fields/BooleanField.test.ts +206 -0
  190. package/tests/client/components/fields/DateField.test.ts +210 -0
  191. package/tests/client/components/fields/EnumField.test.ts +246 -0
  192. package/tests/client/components/fields/NumberField.test.ts +240 -0
  193. package/tests/client/components/fields/ObjectField.test.ts +157 -0
  194. package/tests/client/components/fields/SchemaField.test.ts +190 -0
  195. package/tests/client/components/fields/StringField.test.ts +223 -0
  196. package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
  197. package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
  198. package/tests/client/components/sidebar/sort-mock.ts +23 -0
  199. package/tests/client/js/drafts/fixtures.ts +22 -0
  200. package/tests/client/js/drafts/merge.test.ts +282 -0
  201. package/tests/client/js/drafts/ops.test.ts +658 -0
  202. package/tests/client/js/drafts/storage.test.ts +200 -0
  203. package/tests/client/js/drafts/workers/diff.test.ts +165 -0
  204. package/tests/client/js/editor/editor.test.ts +616 -0
  205. package/tests/client/js/editor/link-wrap.test.ts +225 -0
  206. package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
  207. package/tests/client/js/handlers/admin.test.ts +467 -0
  208. package/tests/client/js/state/router.test.ts +619 -0
  209. package/tests/client/js/state/schema.test.ts +266 -0
  210. package/tests/client/js/state/state.test.ts +328 -0
  211. package/tests/client/js/storage/adapter.test.ts +115 -0
  212. package/tests/client/js/storage/client.test.ts +250 -0
  213. package/tests/client/js/storage/db.test.ts +59 -0
  214. package/tests/client/js/storage/fsa.test.ts +284 -0
  215. package/tests/client/js/storage/github.test.ts +349 -0
  216. package/tests/client/js/storage/mock-port.ts +95 -0
  217. package/tests/client/js/storage/storage.test.ts +77 -0
  218. package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
  219. package/tests/client/js/storage/workers/storage.test.ts +299 -0
  220. package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
  221. package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
  222. package/tests/client/js/utils/file-types.test.ts +268 -0
  223. package/tests/client/js/utils/frontmatter.test.ts +87 -0
  224. package/tests/client/js/utils/schema-utils.test.ts +318 -0
  225. package/tests/client/js/utils/slug.test.ts +58 -0
  226. package/tests/client/js/utils/sort.test.ts +276 -0
  227. package/tests/client/js/utils/stable-stringify.test.ts +68 -0
  228. package/tests/client/js/utils/url-utils.test.ts +70 -0
  229. package/tests/e2e/backend-connection.test.ts +301 -0
  230. package/tests/e2e/draft-lifecycle.test.ts +388 -0
  231. package/tests/e2e/editing.test.ts +355 -0
  232. package/tests/e2e/github-adapter.test.ts +330 -0
  233. package/tests/e2e/helpers/mock-adapter.ts +166 -0
  234. package/tests/e2e/helpers/test-app.ts +155 -0
  235. package/tests/e2e/navigation.test.ts +358 -0
  236. package/tests/e2e/publishing.test.ts +345 -0
  237. package/tests/e2e/unsaved-changes.test.ts +317 -0
  238. package/tests/setup.ts +2 -0
  239. package/tests/stubs/codemirror.ts +197 -0
  240. package/tsconfig.json +19 -0
  241. package/vitest.config.ts +178 -0
@@ -0,0 +1,339 @@
1
+ <script lang="ts">
2
+ import type { SchemaNode } from '../../js/utils/schema-utils';
3
+ import {
4
+ createDefaultValue,
5
+ resolveFieldType,
6
+ getLabel,
7
+ } from '../../js/utils/schema-utils';
8
+ import ArrayItem from './ArrayItem.svelte';
9
+
10
+ /**
11
+ * Props for the ArrayField component, which renders a JSON Schema array field with add, remove, reorder, and drag-and-drop controls.
12
+ */
13
+ interface Props {
14
+ // Property name for the label
15
+ name: string;
16
+ // JSON Schema node describing this array field
17
+ schema: SchemaNode;
18
+ // Current array value
19
+ value: unknown;
20
+ // Whether this field is required
21
+ required?: boolean;
22
+ // Callback fired when the array value changes
23
+ onchange: (value: unknown) => void;
24
+ }
25
+
26
+ let { name, schema, value, required = false, onchange }: Props = $props();
27
+
28
+ /*
29
+ //////////////////////////////
30
+ // Derived schema metadata
31
+ //////////////////////////////
32
+ */
33
+
34
+ // Schema for each item in the array
35
+ const itemSchema = $derived(
36
+ (schema['items'] as SchemaNode | undefined) ?? {},
37
+ );
38
+
39
+ // Whether items are objects — enables collapse UI in ArrayItem
40
+ const isObjectItems = $derived(
41
+ resolveFieldType(itemSchema).kind === 'object',
42
+ );
43
+
44
+ // Minimum number of items allowed (from schema)
45
+ const minItems = $derived(schema['minItems'] as number | undefined);
46
+
47
+ // Maximum number of items allowed (from schema)
48
+ const maxItems = $derived(schema['maxItems'] as number | undefined);
49
+
50
+ // Display label — schema.title if present, otherwise title-cased name
51
+ const label = $derived(getLabel(schema, name));
52
+
53
+ // Current items array, falling back to empty array if value is not an array
54
+ const items = $derived(Array.isArray(value) ? (value as unknown[]) : []);
55
+
56
+ /*
57
+ //////////////////////////////
58
+ // Collapse state
59
+ //////////////////////////////
60
+ */
61
+
62
+ // Collapsed state per item slot; grows/shrinks reactively with the items array.
63
+ let collapsed = $state<boolean[]>([]);
64
+
65
+ // Keep collapsed array length in sync with items without wiping existing state
66
+ $effect(() => {
67
+ const len = items.length;
68
+ if (collapsed.length < len) {
69
+ // Append false entries for any new items
70
+ collapsed = [...collapsed, ...Array(len - collapsed.length).fill(false)];
71
+ } else if (collapsed.length > len) {
72
+ collapsed = collapsed.slice(0, len);
73
+ }
74
+ });
75
+
76
+ /*
77
+ //////////////////////////////
78
+ // Drag-and-drop state
79
+ //////////////////////////////
80
+ */
81
+
82
+ // Index of the item currently being dragged, or -1 when idle
83
+ let dragIndex = $state(-1);
84
+
85
+ // Index of the item currently hovered over as a drop target, or -1 when none
86
+ let dropTarget = $state(-1);
87
+
88
+ /*
89
+ //////////////////////////////
90
+ // Array mutation helpers
91
+ //////////////////////////////
92
+ */
93
+
94
+ /**
95
+ * Appends a new default item to the array, using the item schema to create a default value.
96
+ * Guards against maxItems even when the button is disabled.
97
+ * @return {void}
98
+ */
99
+ function addItem(): void {
100
+ if (maxItems != null && items.length >= maxItems) return;
101
+ const newItem = createDefaultValue(itemSchema);
102
+ onchange([...items, newItem]);
103
+ }
104
+
105
+ /**
106
+ * Removes the item at the given index. Guards against minItems even when the button is disabled.
107
+ * @param {number} index - Zero-based index of the item to remove
108
+ * @return {void}
109
+ */
110
+ function removeItem(index: number): void {
111
+ if (minItems != null && items.length <= minItems) return;
112
+ const next = items.filter((_, i) => i !== index);
113
+ onchange(next);
114
+ }
115
+
116
+ /**
117
+ * Moves an item from one index to another, keeping collapsed state in sync with the reorder.
118
+ * @param {number} from - Zero-based source index of the item to move
119
+ * @param {number} to - Zero-based destination index to move the item to
120
+ * @return {void}
121
+ */
122
+ function moveItem(from: number, to: number): void {
123
+ if (to < 0 || to >= items.length) return;
124
+ const next = [...items];
125
+ const [moved] = next.splice(from, 1);
126
+ next.splice(to, 0, moved);
127
+ // Keep collapsed state in sync with the reorder
128
+ const nextCollapsed = [...collapsed];
129
+ const [movedCollapsed] = nextCollapsed.splice(from, 1);
130
+ nextCollapsed.splice(to, 0, movedCollapsed);
131
+ collapsed = nextCollapsed;
132
+ onchange(next);
133
+ }
134
+
135
+ /**
136
+ * Replaces the item at the given index with a new value and dispatches the updated array.
137
+ * @param {number} index - Zero-based index of the item to update
138
+ * @param {unknown} newValue - The replacement value for the item
139
+ * @return {void}
140
+ */
141
+ function updateItem(index: number, newValue: unknown): void {
142
+ const next = items.map((item, i) => (i === index ? newValue : item));
143
+ onchange(next);
144
+ }
145
+
146
+ /**
147
+ * Toggles the collapsed state for the item at the given index.
148
+ * @param {number} index - Zero-based index of the item whose collapse state to toggle
149
+ * @return {void}
150
+ */
151
+ function toggleCollapse(index: number): void {
152
+ collapsed = collapsed.map((c, i) => (i === index ? !c : c));
153
+ }
154
+
155
+ /*
156
+ //////////////////////////////
157
+ // Drag-and-drop handlers
158
+ //////////////////////////////
159
+ */
160
+
161
+ /**
162
+ * Marks an item as the drag source and sets the drag effect.
163
+ * @param {DragEvent} e - The native dragstart event
164
+ * @param {number} index - Zero-based index of the item being dragged
165
+ * @return {void}
166
+ */
167
+ function handleDragStart(e: DragEvent, index: number): void {
168
+ dragIndex = index;
169
+ if (e.dataTransfer) {
170
+ e.dataTransfer.effectAllowed = 'move';
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Updates the drop target index while dragging over an item.
176
+ * @param {DragEvent} e - The native dragover event (preventDefault allows drop)
177
+ * @param {number} index - Zero-based index of the item currently being dragged over
178
+ * @return {void}
179
+ */
180
+ function handleDragOver(e: DragEvent, index: number): void {
181
+ e.preventDefault();
182
+ dropTarget = index;
183
+ if (e.dataTransfer) {
184
+ e.dataTransfer.dropEffect = 'move';
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Clears the drop target highlight when the drag leaves an item.
190
+ * @param {number} index - Zero-based index of the item being left
191
+ * @return {void}
192
+ */
193
+ function handleDragLeave(index: number): void {
194
+ if (dropTarget === index) dropTarget = -1;
195
+ }
196
+
197
+ /**
198
+ * Completes the drag-and-drop reorder when an item is dropped onto a target slot.
199
+ * @param {number} index - Zero-based index of the slot where the dragged item was dropped
200
+ * @return {void}
201
+ */
202
+ function handleDrop(index: number): void {
203
+ if (dragIndex !== -1 && dragIndex !== index) {
204
+ moveItem(dragIndex, index);
205
+ }
206
+ dragIndex = -1;
207
+ dropTarget = -1;
208
+ }
209
+
210
+ /**
211
+ * Resets drag state after a drag operation ends, including cancelled drags.
212
+ * @return {void}
213
+ */
214
+ function handleDragEnd(): void {
215
+ dragIndex = -1;
216
+ dropTarget = -1;
217
+ }
218
+
219
+ /*
220
+ //////////////////////////////
221
+ // Derived constraint checks
222
+ //////////////////////////////
223
+ */
224
+
225
+ // Whether the add button should be disabled
226
+ const atMax = $derived(maxItems != null && items.length >= maxItems);
227
+
228
+ // Whether removal is permitted (need more than minItems)
229
+ const canRemove = $derived(minItems == null || items.length > minItems);
230
+ </script>
231
+
232
+ {#snippet arrayContent()}
233
+ {#if items.length === 0}
234
+ <p class="array-field__empty">No items</p>
235
+ {:else}
236
+ <div class="array-field__list" role="list">
237
+ {#each items as item, i (i)}
238
+ <ArrayItem
239
+ {name}
240
+ index={i}
241
+ {item}
242
+ {itemSchema}
243
+ isObject={isObjectItems}
244
+ collapsed={collapsed[i] ?? false}
245
+ dragging={dragIndex === i}
246
+ dropTarget={dropTarget === i}
247
+ isFirst={i === 0}
248
+ isLast={i === items.length - 1}
249
+ {canRemove}
250
+ onupdate={updateItem}
251
+ onremove={removeItem}
252
+ onmoveup={(idx) => moveItem(idx, idx - 1)}
253
+ onmovedown={(idx) => moveItem(idx, idx + 1)}
254
+ ontogglecollapse={toggleCollapse}
255
+ ondragstart={(e) => handleDragStart(e, i)}
256
+ ondragover={(e) => handleDragOver(e, i)}
257
+ ondragleave={() => handleDragLeave(i)}
258
+ ondrop={() => handleDrop(i)}
259
+ ondragend={handleDragEnd}
260
+ />
261
+ {/each}
262
+ </div>
263
+ {/if}
264
+
265
+ <button
266
+ class="array-field__add"
267
+ type="button"
268
+ disabled={atMax}
269
+ onclick={addItem}
270
+ >
271
+ + Add item
272
+ </button>
273
+ {/snippet}
274
+
275
+ <fieldset class="array-field">
276
+ <legend class="array-field__label">
277
+ {label}{#if required}<span class="array-field__required" aria-hidden="true"
278
+ >*</span
279
+ >{/if}
280
+ </legend>
281
+ {@render arrayContent()}
282
+ </fieldset>
283
+
284
+ <style>
285
+ .array-field {
286
+ display: grid;
287
+ gap: 0.5rem;
288
+ /* Reset fieldset defaults when used for primitive arrays */
289
+ border: none;
290
+ margin: 0;
291
+ padding: 0;
292
+ min-width: 0;
293
+ }
294
+
295
+ .array-field__label {
296
+ font-size: 0.875rem;
297
+ color: var(--cms-fg);
298
+ padding: 0;
299
+ margin-bottom: 0.25rem;
300
+ }
301
+
302
+ .array-field__required {
303
+ color: var(--light-red);
304
+ margin-left: 0.25rem;
305
+ }
306
+
307
+ .array-field__empty {
308
+ font-size: 0.75rem;
309
+ color: var(--cms-muted);
310
+ margin: 0;
311
+ }
312
+
313
+ .array-field__list {
314
+ display: grid;
315
+ gap: 0.5rem;
316
+ }
317
+
318
+ .array-field__add {
319
+ border: 1px dashed var(--cms-border);
320
+ border-radius: 4px;
321
+ background: none;
322
+ color: var(--cms-muted);
323
+ cursor: pointer;
324
+ font-size: 0.875rem;
325
+ padding: 0.5rem;
326
+ text-align: center;
327
+ width: 100%;
328
+
329
+ &:hover:not(:disabled) {
330
+ border-color: var(--cms-fg);
331
+ color: var(--cms-fg);
332
+ }
333
+
334
+ &:disabled {
335
+ opacity: 0.3;
336
+ cursor: default;
337
+ }
338
+ }
339
+ </style>
@@ -0,0 +1,325 @@
1
+ <script lang="ts">
2
+ import type { SchemaNode } from '../../js/utils/schema-utils';
3
+ import SchemaField from './SchemaField.svelte';
4
+
5
+ // Props for the ArrayItem component, which renders a single item row within an ArrayField, including drag-and-drop handles, reorder arrows, and a remove button.
6
+ interface Props {
7
+ // Parent array field name, used to namespace child field ids
8
+ name: string;
9
+ // Zero-based position of this item in the array
10
+ index: number;
11
+ // The item's current value
12
+ item: unknown;
13
+ // JSON Schema node describing a single array item
14
+ itemSchema: SchemaNode;
15
+ // Whether the item schema is an object type
16
+ isObject: boolean;
17
+ // Whether this item's content is collapsed (only applicable for objects)
18
+ collapsed: boolean;
19
+ // Whether this item is currently being dragged
20
+ dragging: boolean;
21
+ // Whether this item is the current drag-over drop target
22
+ dropTarget: boolean;
23
+ // Whether this is the first item in the list (disables move-up)
24
+ isFirst: boolean;
25
+ // Whether this is the last item in the list (disables move-down)
26
+ isLast: boolean;
27
+ // Whether the remove button is enabled
28
+ canRemove: boolean;
29
+ // Fired when this item's value changes
30
+ onupdate: (index: number, value: unknown) => void;
31
+ // Fired when the remove button is clicked
32
+ onremove: (index: number) => void;
33
+ // Fired when the move-up arrow is clicked
34
+ onmoveup: (index: number) => void;
35
+ // Fired when the move-down arrow is clicked
36
+ onmovedown: (index: number) => void;
37
+ // Fired when the collapse/expand toggle is clicked
38
+ ontogglecollapse: (index: number) => void;
39
+ // Native dragstart handler
40
+ ondragstart: (e: DragEvent) => void;
41
+ // Native dragover handler
42
+ ondragover: (e: DragEvent) => void;
43
+ // Native dragleave handler
44
+ ondragleave: (e: DragEvent) => void;
45
+ // Native drop handler
46
+ ondrop: (e: DragEvent) => void;
47
+ // Native dragend handler
48
+ ondragend: (e: DragEvent) => void;
49
+ }
50
+
51
+ let {
52
+ name,
53
+ index,
54
+ item,
55
+ itemSchema,
56
+ isObject,
57
+ collapsed,
58
+ dragging,
59
+ dropTarget,
60
+ isFirst,
61
+ isLast,
62
+ canRemove,
63
+ onupdate,
64
+ onremove,
65
+ onmoveup,
66
+ onmovedown,
67
+ ontogglecollapse,
68
+ ondragstart,
69
+ ondragover,
70
+ ondragleave,
71
+ ondrop,
72
+ ondragend,
73
+ }: Props = $props();
74
+
75
+ // Schema title for the item type, if present (e.g., "Step")
76
+ const itemTitle = $derived(itemSchema['title'] as string | undefined);
77
+
78
+ // Header label: "{title} N" if schema has a title, first string value, or "Item N".
79
+ const headerLabel = $derived.by(() => {
80
+ if (!isObject) return '';
81
+ if (itemTitle) return `${itemTitle} ${index + 1}`;
82
+ if (typeof item !== 'object' || item === null) return `Item ${index + 1}`;
83
+ const obj = item as Record<string, unknown>;
84
+ const firstString = Object.values(obj).find(
85
+ (v) => typeof v === 'string' && v !== '',
86
+ );
87
+ return typeof firstString === 'string' ? firstString : `Item ${index + 1}`;
88
+ });
89
+ </script>
90
+
91
+ {#snippet actionButtons()}
92
+ <button
93
+ class="array-item__btn"
94
+ type="button"
95
+ aria-label="Move item up"
96
+ disabled={isFirst}
97
+ onclick={() => onmoveup(index)}
98
+ ><span class="icon">arrow_upward</span></button
99
+ >
100
+ <button
101
+ class="array-item__btn"
102
+ type="button"
103
+ aria-label="Move item down"
104
+ disabled={isLast}
105
+ onclick={() => onmovedown(index)}
106
+ ><span class="icon">arrow_downward</span></button
107
+ >
108
+ <button
109
+ class="array-item__btn array-item__btn--remove"
110
+ type="button"
111
+ aria-label="Remove item"
112
+ disabled={!canRemove}
113
+ onclick={() => onremove(index)}><span class="icon">close</span></button
114
+ >
115
+ {/snippet}
116
+
117
+ {#if isObject}
118
+ <fieldset
119
+ class="array-item"
120
+ class:array-item--dragging={dragging}
121
+ class:array-item--drop-target={dropTarget}
122
+ draggable="true"
123
+ {ondragstart}
124
+ {ondragover}
125
+ {ondragleave}
126
+ {ondrop}
127
+ {ondragend}
128
+ >
129
+ <!-- Controls bar with legend for the fieldset label -->
130
+ <div class="array-item__controls">
131
+ <span
132
+ class="array-item__drag-handle"
133
+ aria-hidden="true"
134
+ title="Drag to reorder"><span class="icon">drag_indicator</span></span
135
+ >
136
+ <button
137
+ class="array-item__btn"
138
+ type="button"
139
+ aria-label={collapsed ? 'Expand item' : 'Collapse item'}
140
+ onclick={() => ontogglecollapse(index)}
141
+ >
142
+ <span
143
+ class="icon array-item__collapse-icon"
144
+ class:array-item__collapse-icon--collapsed={collapsed}
145
+ >chevron_right</span
146
+ >
147
+ </button>
148
+ <legend class="array-item__legend">{headerLabel}</legend>
149
+ <span class="array-item__spacer"></span>
150
+ {@render actionButtons()}
151
+ </div>
152
+
153
+ {#if !collapsed}
154
+ <div class="array-item__content">
155
+ <!-- inline=true skips the ObjectField fieldset wrapper -->
156
+ <SchemaField
157
+ name={`${name}[${index}]`}
158
+ schema={itemSchema}
159
+ value={item}
160
+ onchange={(v) => onupdate(index, v)}
161
+ inline={true}
162
+ />
163
+ </div>
164
+ {/if}
165
+ </fieldset>
166
+ {:else}
167
+ <!-- Primitive item: input sits inline between drag handle and buttons -->
168
+ <div
169
+ class="array-item array-item--primitive"
170
+ class:array-item--dragging={dragging}
171
+ class:array-item--drop-target={dropTarget}
172
+ draggable="true"
173
+ role="listitem"
174
+ {ondragstart}
175
+ {ondragover}
176
+ {ondragleave}
177
+ {ondrop}
178
+ {ondragend}
179
+ >
180
+ <div class="array-item__controls">
181
+ <span
182
+ class="array-item__drag-handle"
183
+ aria-hidden="true"
184
+ title="Drag to reorder"><span class="icon">drag_indicator</span></span
185
+ >
186
+ <!-- Inline input with aria-label only, no visible label -->
187
+ <div class="array-item__inline-field">
188
+ <SchemaField
189
+ name={`${name}[${index}]`}
190
+ schema={itemSchema}
191
+ value={item}
192
+ onchange={(v) => onupdate(index, v)}
193
+ inline={true}
194
+ />
195
+ </div>
196
+ {@render actionButtons()}
197
+ </div>
198
+ </div>
199
+ {/if}
200
+
201
+ <style>
202
+ .array-item {
203
+ border: 1px solid var(--cms-border);
204
+ border-radius: 4px;
205
+ background: var(--cms-surface, #1e1e22);
206
+ transition:
207
+ opacity 0.15s,
208
+ border-color 0.15s;
209
+ /* Reset fieldset defaults */
210
+ margin: 0;
211
+ padding: 0;
212
+ min-width: 0;
213
+ }
214
+
215
+ .array-item--dragging {
216
+ opacity: 0.5;
217
+ }
218
+
219
+ .array-item--drop-target {
220
+ border-color: var(--plum);
221
+ }
222
+
223
+ .array-item__controls {
224
+ display: flex;
225
+ align-items: center;
226
+ padding: 0.5rem;
227
+ }
228
+
229
+ .array-item__drag-handle {
230
+ color: var(--cms-muted);
231
+ cursor: grab;
232
+ user-select: none;
233
+ display: grid;
234
+ place-items: center;
235
+
236
+ &:active {
237
+ cursor: grabbing;
238
+ }
239
+ }
240
+
241
+ /* Chevron rotates 90deg when expanded (default), points right when collapsed */
242
+ .array-item__collapse-icon {
243
+ transition: transform 0.15s;
244
+ transform: rotate(90deg);
245
+ display: block;
246
+ }
247
+
248
+ .array-item__collapse-icon--collapsed {
249
+ transform: rotate(0deg);
250
+ }
251
+
252
+ /* Consistent icon size for all Material Symbols in array items */
253
+ .icon {
254
+ font-size: 1.25rem;
255
+ /* Ensure vertical centering in flex row */
256
+ display: grid;
257
+ place-items: center;
258
+ }
259
+
260
+ /* Smaller icons for action buttons (arrows + close) */
261
+ .array-item__btn .icon {
262
+ font-size: 1rem;
263
+ }
264
+
265
+ /* Legend rendered inline in the controls flex row, after the drag handle and chevron */
266
+ .array-item__legend {
267
+ font-size: 0.875rem;
268
+ color: var(--cms-muted);
269
+ overflow: hidden;
270
+ text-overflow: ellipsis;
271
+ white-space: nowrap;
272
+ min-width: 0;
273
+ margin-left: 0.25rem;
274
+ /* Reset legend defaults so it participates in flex layout */
275
+ padding: 0;
276
+ float: unset;
277
+ width: auto;
278
+ }
279
+
280
+ .array-item__spacer {
281
+ flex: 1;
282
+ }
283
+
284
+ /*
285
+ * Primitive items: input fills space between drag handle and buttons.
286
+ * Label/help hiding is handled by FieldWrapper's inline prop via SchemaField inline={true}.
287
+ */
288
+ .array-item__inline-field {
289
+ flex: 1;
290
+ min-width: 0;
291
+ margin: 0 0.5rem;
292
+ }
293
+
294
+ .array-item__btn {
295
+ background: none;
296
+ border: none;
297
+ color: var(--cms-muted);
298
+ cursor: pointer;
299
+ /* Minimal padding so action buttons sit tight together */
300
+ padding: 0;
301
+ line-height: 1;
302
+
303
+ &:hover:not(:disabled) {
304
+ color: var(--cms-fg);
305
+ }
306
+
307
+ &:disabled {
308
+ opacity: 0.3;
309
+ cursor: default;
310
+ }
311
+ }
312
+
313
+ /* Small gap before the remove button to visually separate it from arrows */
314
+ .array-item__btn--remove {
315
+ margin-left: 0.25rem;
316
+
317
+ &:hover:not(:disabled) {
318
+ color: var(--light-red);
319
+ }
320
+ }
321
+
322
+ .array-item__content {
323
+ padding: 0.75rem;
324
+ }
325
+ </style>