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.
- package/.claude/settings.local.json +42 -0
- package/.github/workflows/ci.yml +31 -0
- package/.mcp.json +12 -0
- package/.prettierignore +5 -0
- package/.prettierrc.cjs +22 -0
- package/AGENTS.md +183 -0
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/package.json +74 -0
- package/playground/.claude/settings.local.json +5 -0
- package/playground/astro.config.mjs +7 -0
- package/playground/node_modules/.bin/astro +21 -0
- package/playground/node_modules/.bin/rollup +21 -0
- package/playground/node_modules/.bin/tsc +21 -0
- package/playground/node_modules/.bin/tsserver +21 -0
- package/playground/node_modules/.bin/vite +21 -0
- package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
- package/playground/node_modules/.vite/deps/_metadata.json +184 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
- package/playground/node_modules/.vite/deps/package.json +3 -0
- package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
- package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte.js +55 -0
- package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
- package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
- package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
- package/playground/package.json +16 -0
- package/playground/pnpm-lock.yaml +3167 -0
- package/playground/src/content/authors/jane-doe.json +8 -0
- package/playground/src/content/config/build.toml +2 -0
- package/playground/src/content/courses/web-fundamentals.json +29 -0
- package/playground/src/content/docs/advanced.mdx +6 -0
- package/playground/src/content/docs/intro.md +6 -0
- package/playground/src/content/guides/getting-started.mdx +6 -0
- package/playground/src/content/posts/hello-world.md +7 -0
- package/playground/src/content/products/t-shirt.json +16 -0
- package/playground/src/content/recipes/pancakes.mdoc +8 -0
- package/playground/src/content/settings/site.yml +2 -0
- package/playground/src/content.config.ts +198 -0
- package/playground/src/env.d.ts +1 -0
- package/playground/src/pages/index.astro +11 -0
- package/playground/src/pages/nebula.astro +14 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/subset-icons.mjs +178 -0
- package/src/astro/index.ts +295 -0
- package/src/client/Admin.svelte +283 -0
- package/src/client/components/BackendPicker.svelte +291 -0
- package/src/client/components/DraftChip.svelte +46 -0
- package/src/client/components/MetadataForm.svelte +56 -0
- package/src/client/components/ThemeToggle.svelte +18 -0
- package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
- package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
- package/src/client/components/editor/EditorPane.svelte +227 -0
- package/src/client/components/editor/EditorTabs.svelte +81 -0
- package/src/client/components/editor/EditorToolbar.svelte +131 -0
- package/src/client/components/editor/FormatSelector.svelte +66 -0
- package/src/client/components/editor/Toolbar.svelte +17 -0
- package/src/client/components/fields/ArrayField.svelte +339 -0
- package/src/client/components/fields/ArrayItem.svelte +325 -0
- package/src/client/components/fields/BooleanField.svelte +114 -0
- package/src/client/components/fields/DateField.svelte +82 -0
- package/src/client/components/fields/EnumField.svelte +74 -0
- package/src/client/components/fields/FieldWrapper.svelte +96 -0
- package/src/client/components/fields/NumberField.svelte +99 -0
- package/src/client/components/fields/ObjectField.svelte +121 -0
- package/src/client/components/fields/SchemaField.svelte +107 -0
- package/src/client/components/fields/StringField.svelte +104 -0
- package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
- package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
- package/src/client/css/a11y.css +14 -0
- package/src/client/css/btn.css +113 -0
- package/src/client/css/dialog.css +29 -0
- package/src/client/css/field-input.css +39 -0
- package/src/client/css/reset.css +59 -0
- package/src/client/css/theme.css +77 -0
- package/src/client/index.ts +1 -0
- package/src/client/js/drafts/merge.svelte.ts +121 -0
- package/src/client/js/drafts/ops.svelte.ts +227 -0
- package/src/client/js/drafts/storage.ts +108 -0
- package/src/client/js/drafts/workers/diff.ts +40 -0
- package/src/client/js/editor/editor.svelte.ts +343 -0
- package/src/client/js/editor/languages.ts +98 -0
- package/src/client/js/editor/link-wrap.ts +45 -0
- package/src/client/js/editor/markdown-shortcuts.ts +261 -0
- package/src/client/js/handlers/admin.ts +246 -0
- package/src/client/js/state/dialogs.svelte.ts +35 -0
- package/src/client/js/state/router.svelte.ts +156 -0
- package/src/client/js/state/schema.svelte.ts +140 -0
- package/src/client/js/state/state.svelte.ts +334 -0
- package/src/client/js/state/theme.svelte.ts +173 -0
- package/src/client/js/storage/adapter.ts +102 -0
- package/src/client/js/storage/client.ts +150 -0
- package/src/client/js/storage/db.ts +36 -0
- package/src/client/js/storage/fsa.ts +110 -0
- package/src/client/js/storage/github.ts +297 -0
- package/src/client/js/storage/storage.ts +83 -0
- package/src/client/js/storage/workers/frontmatter.ts +320 -0
- package/src/client/js/storage/workers/storage.ts +177 -0
- package/src/client/js/storage/workers/toml-parser.ts +106 -0
- package/src/client/js/storage/workers/yaml-parser.ts +132 -0
- package/src/client/js/utils/file-types.ts +192 -0
- package/src/client/js/utils/format.ts +16 -0
- package/src/client/js/utils/frontmatter.ts +38 -0
- package/src/client/js/utils/schema-utils.ts +295 -0
- package/src/client/js/utils/slug.ts +18 -0
- package/src/client/js/utils/sort.ts +84 -0
- package/src/client/js/utils/stable-stringify.ts +27 -0
- package/src/client/js/utils/url-utils.ts +38 -0
- package/src/types.ts +25 -0
- package/src/virtual.d.ts +22 -0
- package/svelte.config.js +4 -0
- package/tests/astro/build.test.ts +63 -0
- package/tests/astro/index.test.ts +689 -0
- package/tests/client/components/Admin.test.ts +446 -0
- package/tests/client/components/BackendPicker.test.ts +239 -0
- package/tests/client/components/DraftChip.test.ts +53 -0
- package/tests/client/components/MetadataForm.test.ts +164 -0
- package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
- package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
- package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
- package/tests/client/components/editor/EditorPane.test.ts +100 -0
- package/tests/client/components/editor/EditorTabs.test.ts +253 -0
- package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
- package/tests/client/components/editor/fixtures.ts +31 -0
- package/tests/client/components/fields/ArrayField.test.ts +197 -0
- package/tests/client/components/fields/BooleanField.test.ts +206 -0
- package/tests/client/components/fields/DateField.test.ts +210 -0
- package/tests/client/components/fields/EnumField.test.ts +246 -0
- package/tests/client/components/fields/NumberField.test.ts +240 -0
- package/tests/client/components/fields/ObjectField.test.ts +157 -0
- package/tests/client/components/fields/SchemaField.test.ts +190 -0
- package/tests/client/components/fields/StringField.test.ts +223 -0
- package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
- package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
- package/tests/client/components/sidebar/sort-mock.ts +23 -0
- package/tests/client/js/drafts/fixtures.ts +22 -0
- package/tests/client/js/drafts/merge.test.ts +282 -0
- package/tests/client/js/drafts/ops.test.ts +658 -0
- package/tests/client/js/drafts/storage.test.ts +200 -0
- package/tests/client/js/drafts/workers/diff.test.ts +165 -0
- package/tests/client/js/editor/editor.test.ts +616 -0
- package/tests/client/js/editor/link-wrap.test.ts +225 -0
- package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
- package/tests/client/js/handlers/admin.test.ts +467 -0
- package/tests/client/js/state/router.test.ts +619 -0
- package/tests/client/js/state/schema.test.ts +266 -0
- package/tests/client/js/state/state.test.ts +328 -0
- package/tests/client/js/storage/adapter.test.ts +115 -0
- package/tests/client/js/storage/client.test.ts +250 -0
- package/tests/client/js/storage/db.test.ts +59 -0
- package/tests/client/js/storage/fsa.test.ts +284 -0
- package/tests/client/js/storage/github.test.ts +349 -0
- package/tests/client/js/storage/mock-port.ts +95 -0
- package/tests/client/js/storage/storage.test.ts +77 -0
- package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
- package/tests/client/js/storage/workers/storage.test.ts +299 -0
- package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
- package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
- package/tests/client/js/utils/file-types.test.ts +268 -0
- package/tests/client/js/utils/frontmatter.test.ts +87 -0
- package/tests/client/js/utils/schema-utils.test.ts +318 -0
- package/tests/client/js/utils/slug.test.ts +58 -0
- package/tests/client/js/utils/sort.test.ts +276 -0
- package/tests/client/js/utils/stable-stringify.test.ts +68 -0
- package/tests/client/js/utils/url-utils.test.ts +70 -0
- package/tests/e2e/backend-connection.test.ts +301 -0
- package/tests/e2e/draft-lifecycle.test.ts +388 -0
- package/tests/e2e/editing.test.ts +355 -0
- package/tests/e2e/github-adapter.test.ts +330 -0
- package/tests/e2e/helpers/mock-adapter.ts +166 -0
- package/tests/e2e/helpers/test-app.ts +155 -0
- package/tests/e2e/navigation.test.ts +358 -0
- package/tests/e2e/publishing.test.ts +345 -0
- package/tests/e2e/unsaved-changes.test.ts +317 -0
- package/tests/setup.ts +2 -0
- package/tests/stubs/codemirror.ts +197 -0
- package/tsconfig.json +19 -0
- 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>
|