@glw907/cairn-cms 0.56.1 → 0.57.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/CHANGELOG.md +148 -0
- package/README.md +10 -4
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +929 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/ComponentForm.svelte +175 -46
- package/dist/components/ComponentForm.svelte.d.ts +22 -8
- package/dist/components/ComponentInsertDialog.svelte +379 -26
- package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
- package/dist/components/EditPage.svelte +477 -15
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +358 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +51 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +569 -0
- package/dist/components/MediaHeroField.svelte.d.ts +67 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +1045 -28
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +19 -0
- package/dist/components/markdown-directives.js +52 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +17 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +62 -11
- package/dist/content/validate.js +27 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/component-grammar.d.ts +20 -0
- package/dist/render/component-grammar.js +47 -3
- package/dist/render/component-validate.js +22 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.js +15 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +68 -2
- package/dist/sveltekit/content-routes.js +461 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +8 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +929 -0
- package/src/lib/components/ComponentForm.svelte +175 -46
- package/src/lib/components/ComponentInsertDialog.svelte +379 -26
- package/src/lib/components/EditPage.svelte +477 -15
- package/src/lib/components/MarkdownEditor.svelte +358 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +569 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +57 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +16 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +78 -13
- package/src/lib/content/validate.ts +26 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/component-grammar.ts +59 -3
- package/src/lib/render/component-validate.ts +22 -1
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +38 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +573 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MediaLibraryData, ContentFormFailure } from '../sveltekit/content-routes.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** The media library load's data: the unioned assets, the per-hash usage overlay, and a
|
|
4
|
+
* degraded-load error. */
|
|
5
|
+
data: MediaLibraryData;
|
|
6
|
+
/** The last media action's result. A `?/mediaDelete` refusal carries the fresh breaking list
|
|
7
|
+
* the in-use face re-opens on; a `?/mediaUpdate` failure carries the error the slide-over
|
|
8
|
+
* surfaces. The route exports one `form`, so this is the merged `ContentFormFailure`. */
|
|
9
|
+
form?: ContentFormFailure | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The admin Media Library screen, a peer of Posts and Pages. It browses every committed media asset,
|
|
13
|
+
* shows where each one is used, edits its name and default alt, and deletes it safely. The resting
|
|
14
|
+
* surface is a visual contact-sheet grid (a roving-tabindex listbox of tiles), with a list-density
|
|
15
|
+
* toggle that flips to an enriched sortable table. One toolbar row carries search, a pick-one triage
|
|
16
|
+
* radiogroup (All, Needs alt, Unused), and the density toggle. Filtering, sorting, and a growing
|
|
17
|
+
* client window all run over the full loaded set in component state.
|
|
18
|
+
*
|
|
19
|
+
* Activating a tile or row opens a NON-MODAL detail slide-over from the right (the established
|
|
20
|
+
* details-slide-over recipe): no scrim, the library stays live and in the a11y tree behind it, Escape
|
|
21
|
+
* closes it, focus moves in on open and returns to the originating tile or row on close. It is a
|
|
22
|
+
* labelled region, not a dialog, so it never traps focus or inerts the list. It holds the large
|
|
23
|
+
* preview, the name and the `media:` reference with a copy button, the alt editor (a describe or
|
|
24
|
+
* decorative radiogroup plus the alt field, posting to `?/mediaUpdate` together with the display name
|
|
25
|
+
* and slug), the where-used list grouped published-then-branch, the metadata grid, and the actions.
|
|
26
|
+
*
|
|
27
|
+
* Delete opens a two-faced safe-delete alertdialog: a native modal `<dialog>` with no light dismiss.
|
|
28
|
+
* The in-use face names the breaking entries and gates Delete behind a typed-slug confirmation; the
|
|
29
|
+
* orphan face is a calm confirm. Both post to `?/mediaDelete`. A `form` carrying a fresh
|
|
30
|
+
* `MediaDeleteRefusal` re-opens the in-use face on its fresh breaking list.
|
|
31
|
+
*
|
|
32
|
+
* It is node-safe by construction: it types assets with MediaLibraryEntry from the shared node-safe
|
|
33
|
+
* projection and pulls in no editor module (the editor-boundary test bars a @codemirror leak).
|
|
34
|
+
*/
|
|
35
|
+
declare const CairnMediaLibrary: import("svelte").Component<Props, {}, "">;
|
|
36
|
+
type CairnMediaLibrary = ReturnType<typeof CairnMediaLibrary>;
|
|
37
|
+
export default CairnMediaLibrary;
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The schema-driven fill form for one component
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The schema-driven fill form for one component, the left column of the configure step. It holds the
|
|
4
|
+
working ComponentValues, seeded from previewValues(def) (the emptyValues base with any declared
|
|
5
|
+
preview sample overlaid), and renders attribute fields and the title/body and other slots. Required
|
|
6
|
+
fields carry an asterisk and aria-required, and Insert disables while any required field is empty.
|
|
7
|
+
Submit serializes and validates through buildComponentInsert and calls onInsert with the markdown.
|
|
8
|
+
This is not a nested HTML form; Insert calls a callback. The dialog owns the header (the Insert >
|
|
9
|
+
group breadcrumb and the Back control) and, in the two-pane case, the preview pane; this component
|
|
10
|
+
binds out its live `values` and `incomplete` so the dialog can render that preview.
|
|
7
11
|
-->
|
|
8
12
|
<script lang="ts">
|
|
9
13
|
import { untrack } from 'svelte';
|
|
10
|
-
import {
|
|
14
|
+
import { previewValues, type ComponentDef, type ComponentValues } from '../render/registry.js';
|
|
11
15
|
import { buildComponentInsert } from '../render/component-insert.js';
|
|
12
16
|
import type { IconSet } from '../render/glyph.js';
|
|
13
17
|
import IconPicker from './IconPicker.svelte';
|
|
@@ -17,15 +21,39 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
17
21
|
icons?: IconSet;
|
|
18
22
|
/** Called with the serialized markdown when the form validates. */
|
|
19
23
|
onInsert: (markdown: string) => void;
|
|
20
|
-
/**
|
|
21
|
-
|
|
24
|
+
/** The live working values, bound out so a host (the dialog) can render a preview from them. */
|
|
25
|
+
values?: ComponentValues;
|
|
26
|
+
/** True while a required attribute or slot is still empty, bound out so the host's preview can
|
|
27
|
+
* show the incomplete state and the host can mirror the disabled Insert. */
|
|
28
|
+
incomplete?: boolean;
|
|
29
|
+
/** Seed the working values from these instead of the schema's preview sample. The dialog passes
|
|
30
|
+
* it in edit mode to re-open a placed component into its own values; the catalog insert path
|
|
31
|
+
* leaves it unset and keeps the previewValues seed. */
|
|
32
|
+
initial?: ComponentValues;
|
|
33
|
+
/** The submit button's label. The dialog passes 'Update' in edit mode; the insert path keeps
|
|
34
|
+
* the default. */
|
|
35
|
+
submitLabel?: string;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
|
-
let {
|
|
38
|
+
let {
|
|
39
|
+
def,
|
|
40
|
+
icons,
|
|
41
|
+
onInsert,
|
|
42
|
+
values = $bindable(),
|
|
43
|
+
incomplete = $bindable(),
|
|
44
|
+
initial,
|
|
45
|
+
submitLabel = 'Insert',
|
|
46
|
+
}: Props = $props();
|
|
25
47
|
|
|
26
|
-
// Working values, seeded once from
|
|
27
|
-
//
|
|
28
|
-
|
|
48
|
+
// Working values, seeded once from `initial` in edit mode, otherwise from the schema and any
|
|
49
|
+
// declared preview sample. $state makes the nested records deeply reactive. untrack marks the
|
|
50
|
+
// seed as a deliberate one-time read, not a reactive miss. previewValues falls back to
|
|
51
|
+
// emptyValues when no preview.
|
|
52
|
+
let working = $state(untrack(() => initial ?? previewValues(def)));
|
|
53
|
+
// Mirror the working values out to the bindable prop so the dialog's preview reads them live.
|
|
54
|
+
$effect(() => {
|
|
55
|
+
values = working;
|
|
56
|
+
});
|
|
29
57
|
|
|
30
58
|
const attributes = $derived(def.attributes ?? []);
|
|
31
59
|
// Non-repeatable slots render here; the repeatable list is handled separately.
|
|
@@ -34,22 +62,32 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
34
62
|
|
|
35
63
|
// The live $state proxy array for a repeatable slot, so push/splice stay reactive.
|
|
36
64
|
function slotItems(name: string): string[] {
|
|
37
|
-
const v =
|
|
65
|
+
const v = working.slots[name];
|
|
38
66
|
return Array.isArray(v) ? v : [];
|
|
39
67
|
}
|
|
40
68
|
|
|
41
69
|
// Stable per-item ids run parallel to each repeatable slot's value array, so the {#each} keys by
|
|
42
70
|
// identity instead of index. A mid-list removal then drops the right DOM node and the focused
|
|
43
71
|
// item follows the data. Ids come from a monotonic module-local counter, never Math.random or
|
|
44
|
-
// Date.now. The value arrays in
|
|
45
|
-
// reads, so the emitted markdown is unchanged.
|
|
46
|
-
//
|
|
72
|
+
// Date.now. The value arrays in working.slots stay the canonical string lists serializeComponent
|
|
73
|
+
// reads, so the emitted markdown is unchanged. The preview seed can fill a repeatable slot, so
|
|
74
|
+
// each seeded item needs a parallel id from the start.
|
|
47
75
|
let nextId = 0;
|
|
48
76
|
const itemIds = $state<Record<string, number[]>>(
|
|
49
|
-
untrack(() =>
|
|
77
|
+
untrack(() =>
|
|
78
|
+
Object.fromEntries(
|
|
79
|
+
(def.slots ?? [])
|
|
80
|
+
.filter((s) => s.kind === 'repeatable')
|
|
81
|
+
.map((s) => {
|
|
82
|
+
const seeded = working.slots[s.name];
|
|
83
|
+
const count = Array.isArray(seeded) ? seeded.length : 0;
|
|
84
|
+
return [s.name, Array.from({ length: count }, () => nextId++)];
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
50
88
|
);
|
|
51
89
|
|
|
52
|
-
//
|
|
90
|
+
// previewValues and the itemIds seed both cover every repeatable slot, so this read always hits.
|
|
53
91
|
function slotIds(name: string): number[] {
|
|
54
92
|
return itemIds[name] ?? [];
|
|
55
93
|
}
|
|
@@ -64,41 +102,106 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
64
102
|
slotIds(name).splice(index, 1);
|
|
65
103
|
}
|
|
66
104
|
|
|
105
|
+
// The row label for a repeatable item: the slot's itemLabel over the item's values and index,
|
|
106
|
+
// falling back to `${label} ${i + 1}` when it returns nothing. v1 repeatable items hold a single
|
|
107
|
+
// string under the first item field's key, so the item record passed to itemLabel carries it.
|
|
108
|
+
function rowLabel(slot: (typeof repeatableSlots)[number], value: string, index: number): string {
|
|
109
|
+
const fallback = `${slot.label} ${index + 1}`;
|
|
110
|
+
if (!slot.itemLabel) return fallback;
|
|
111
|
+
const key = slot.itemFields?.[0]?.key ?? 'text';
|
|
112
|
+
const derived = slot.itemLabel({ [key]: value }, index);
|
|
113
|
+
return derived && derived.trim() ? derived : fallback;
|
|
114
|
+
}
|
|
115
|
+
|
|
67
116
|
// Typed accessors over the unions so explicit value targets stay sound.
|
|
68
117
|
function asString(key: string): string {
|
|
69
|
-
const v =
|
|
118
|
+
const v = working.attributes[key];
|
|
70
119
|
return typeof v === 'string' ? v : '';
|
|
71
120
|
}
|
|
72
121
|
function asBool(key: string): boolean {
|
|
73
|
-
return
|
|
122
|
+
return working.attributes[key] === true;
|
|
74
123
|
}
|
|
75
124
|
function slotString(name: string): string {
|
|
76
|
-
const v =
|
|
125
|
+
const v = working.slots[name];
|
|
77
126
|
return typeof v === 'string' ? v : '';
|
|
78
127
|
}
|
|
79
128
|
|
|
80
|
-
//
|
|
81
|
-
|
|
129
|
+
// A required attribute is unmet only for a text/select/icon field left empty; a boolean is always
|
|
130
|
+
// met (its false is a real choice). A required slot is unmet when its string is empty or its
|
|
131
|
+
// repeatable list has no non-empty item. This drives the asterisk-marked fields, the disabled
|
|
132
|
+
// Insert, and (through the bound `incomplete`) the dialog's incomplete preview state.
|
|
133
|
+
const incompleteState = $derived.by(() => {
|
|
134
|
+
for (const field of attributes) {
|
|
135
|
+
if (!field.required || field.type === 'boolean') continue;
|
|
136
|
+
if (asString(field.key) === '') return true;
|
|
137
|
+
}
|
|
138
|
+
for (const slot of def.slots ?? []) {
|
|
139
|
+
if (!slot.required) continue;
|
|
140
|
+
const v = working.slots[slot.name];
|
|
141
|
+
const filled = Array.isArray(v) ? v.some((i) => i !== '') : typeof v === 'string' && v !== '';
|
|
142
|
+
if (!filled) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
});
|
|
146
|
+
$effect(() => {
|
|
147
|
+
incomplete = incompleteState;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Field-keyed validation errors from the last submit (pattern, validate, select-domain), keyed by
|
|
151
|
+
// attribute key or slot name.
|
|
152
|
+
let submitErrors = $state<Record<string, string>>({});
|
|
153
|
+
|
|
154
|
+
// Fields the editor has touched, so a required-empty error shows after interaction rather than on
|
|
155
|
+
// a fresh open. Keyed by attribute key or slot name.
|
|
156
|
+
let touched = $state<Record<string, boolean>>({});
|
|
157
|
+
function markTouched(key: string): void {
|
|
158
|
+
touched[key] = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The visible field errors. Only the required-empty message ("{label} is required.") shows live,
|
|
162
|
+
// for a touched-and-empty required field; pattern and validate errors surface on submit. Both are
|
|
163
|
+
// merged here, the submit errors last so a pattern or validate message wins. Insert stays disabled
|
|
164
|
+
// while incompleteState holds, so a required-empty field never serializes; this surfaces the why
|
|
165
|
+
// next to the field meanwhile.
|
|
166
|
+
const errors = $derived.by(() => {
|
|
167
|
+
const out: Record<string, string> = {};
|
|
168
|
+
for (const field of attributes) {
|
|
169
|
+
if (field.required && field.type !== 'boolean' && touched[field.key] && asString(field.key) === '') {
|
|
170
|
+
out[field.key] = `${field.label} is required.`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const slot of def.slots ?? []) {
|
|
174
|
+
if (!slot.required) continue;
|
|
175
|
+
const v = working.slots[slot.name];
|
|
176
|
+
const filled = Array.isArray(v) ? v.some((i) => i !== '') : typeof v === 'string' && v !== '';
|
|
177
|
+
if (touched[slot.name] && !filled) out[slot.name] = `${slot.label} is required.`;
|
|
178
|
+
}
|
|
179
|
+
return { ...out, ...submitErrors };
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// The form container. Once it is bound, focus its first focusable control so the editor types
|
|
183
|
+
// straight into the form. The effect tracks formEl so it runs when the node lands; the focus call
|
|
184
|
+
// is untracked so a later value change does not steal focus back to the first field.
|
|
185
|
+
let formEl = $state<HTMLElement | null>(null);
|
|
186
|
+
$effect(() => {
|
|
187
|
+
if (!formEl) return;
|
|
188
|
+
untrack(() => formEl!.querySelector<HTMLElement>('input, select, textarea')?.focus());
|
|
189
|
+
});
|
|
82
190
|
|
|
83
191
|
// Serialize and validate through the pure helper. On success clear errors and emit the markdown;
|
|
84
192
|
// on failure keep the field-keyed errors so each field can show its message and insert nothing.
|
|
85
193
|
async function submit() {
|
|
86
|
-
const result = await buildComponentInsert(def,
|
|
194
|
+
const result = await buildComponentInsert(def, working);
|
|
87
195
|
if (result.ok) {
|
|
88
|
-
|
|
196
|
+
submitErrors = {};
|
|
89
197
|
onInsert(result.markdown);
|
|
90
198
|
} else {
|
|
91
|
-
|
|
199
|
+
submitErrors = result.errors;
|
|
92
200
|
}
|
|
93
201
|
}
|
|
94
202
|
</script>
|
|
95
203
|
|
|
96
|
-
<div class="flex flex-col gap-3">
|
|
97
|
-
<div class="flex items-center justify-between">
|
|
98
|
-
<h3 class="text-sm font-semibold">{def.label}</h3>
|
|
99
|
-
<button type="button" class="btn btn-ghost btn-xs" onclick={onBack}>Back</button>
|
|
100
|
-
</div>
|
|
101
|
-
|
|
204
|
+
<div class="flex flex-col gap-3" bind:this={formEl}>
|
|
102
205
|
{#each attributes as field (field.key)}
|
|
103
206
|
{#if field.type === 'boolean'}
|
|
104
207
|
<label class="label cursor-pointer justify-start gap-2">
|
|
@@ -108,19 +211,24 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
108
211
|
aria-invalid={Boolean(errors[field.key])}
|
|
109
212
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
110
213
|
checked={asBool(field.key)}
|
|
111
|
-
onchange={(e) => (
|
|
214
|
+
onchange={(e) => (working.attributes[field.key] = e.currentTarget.checked)}
|
|
112
215
|
/>
|
|
113
216
|
<span class="text-sm">{field.label}</span>
|
|
114
217
|
</label>
|
|
115
218
|
{:else if field.type === 'select'}
|
|
116
219
|
<label class="flex flex-col gap-1">
|
|
117
|
-
<span class="text-sm font-medium">{field.label}</span>
|
|
220
|
+
<span class="text-sm font-medium">{field.label}{#if field.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
|
|
118
221
|
<select
|
|
119
222
|
class="select"
|
|
223
|
+
aria-required={field.required ? 'true' : undefined}
|
|
120
224
|
aria-invalid={Boolean(errors[field.key])}
|
|
121
225
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
122
226
|
value={asString(field.key)}
|
|
123
|
-
onchange={(e) =>
|
|
227
|
+
onchange={(e) => {
|
|
228
|
+
working.attributes[field.key] = e.currentTarget.value;
|
|
229
|
+
markTouched(field.key);
|
|
230
|
+
}}
|
|
231
|
+
onblur={() => markTouched(field.key)}
|
|
124
232
|
>
|
|
125
233
|
{#if !field.required}<option value="">—</option>{/if}
|
|
126
234
|
{#each field.options ?? [] as opt (opt)}<option value={opt}>{opt}</option>{/each}
|
|
@@ -128,24 +236,29 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
128
236
|
</label>
|
|
129
237
|
{:else if field.type === 'icon' && icons}
|
|
130
238
|
<div class="flex flex-col gap-1">
|
|
131
|
-
<span class="text-sm font-medium">{field.label}</span>
|
|
239
|
+
<span class="text-sm font-medium">{field.label}{#if field.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
|
|
132
240
|
<IconPicker
|
|
133
241
|
{icons}
|
|
134
242
|
label={field.label}
|
|
135
243
|
value={asString(field.key)}
|
|
136
244
|
required={field.required ?? false}
|
|
137
|
-
onChange={(name) => (
|
|
245
|
+
onChange={(name) => (working.attributes[field.key] = name)}
|
|
138
246
|
/>
|
|
139
247
|
</div>
|
|
140
248
|
{:else}
|
|
141
249
|
<label class="flex flex-col gap-1">
|
|
142
|
-
<span class="text-sm font-medium">{field.label}</span>
|
|
250
|
+
<span class="text-sm font-medium">{field.label}{#if field.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
|
|
143
251
|
<input
|
|
144
252
|
class="input"
|
|
253
|
+
aria-required={field.required ? 'true' : undefined}
|
|
145
254
|
aria-invalid={Boolean(errors[field.key])}
|
|
146
255
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
147
256
|
value={asString(field.key)}
|
|
148
|
-
oninput={(e) =>
|
|
257
|
+
oninput={(e) => {
|
|
258
|
+
working.attributes[field.key] = e.currentTarget.value;
|
|
259
|
+
markTouched(field.key);
|
|
260
|
+
}}
|
|
261
|
+
onblur={() => markTouched(field.key)}
|
|
149
262
|
/>
|
|
150
263
|
</label>
|
|
151
264
|
{/if}
|
|
@@ -155,25 +268,35 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
155
268
|
{#each flatSlots as slot (slot.name)}
|
|
156
269
|
{#if slot.kind === 'markdown'}
|
|
157
270
|
<label class="flex flex-col gap-1">
|
|
158
|
-
<span class="text-sm font-medium">{slot.label}</span>
|
|
271
|
+
<span class="text-sm font-medium">{slot.label}{#if slot.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
|
|
159
272
|
<textarea
|
|
160
273
|
class="textarea"
|
|
274
|
+
aria-required={slot.required ? 'true' : undefined}
|
|
161
275
|
aria-invalid={Boolean(errors[slot.name])}
|
|
162
276
|
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
163
277
|
rows={3}
|
|
164
278
|
value={slotString(slot.name)}
|
|
165
|
-
oninput={(e) =>
|
|
279
|
+
oninput={(e) => {
|
|
280
|
+
working.slots[slot.name] = e.currentTarget.value;
|
|
281
|
+
markTouched(slot.name);
|
|
282
|
+
}}
|
|
283
|
+
onblur={() => markTouched(slot.name)}
|
|
166
284
|
></textarea>
|
|
167
285
|
</label>
|
|
168
286
|
{:else}
|
|
169
287
|
<label class="flex flex-col gap-1">
|
|
170
|
-
<span class="text-sm font-medium">{slot.label}</span>
|
|
288
|
+
<span class="text-sm font-medium">{slot.label}{#if slot.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
|
|
171
289
|
<input
|
|
172
290
|
class="input"
|
|
291
|
+
aria-required={slot.required ? 'true' : undefined}
|
|
173
292
|
aria-invalid={Boolean(errors[slot.name])}
|
|
174
293
|
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
175
294
|
value={slotString(slot.name)}
|
|
176
|
-
oninput={(e) =>
|
|
295
|
+
oninput={(e) => {
|
|
296
|
+
working.slots[slot.name] = e.currentTarget.value;
|
|
297
|
+
markTouched(slot.name);
|
|
298
|
+
}}
|
|
299
|
+
onblur={() => markTouched(slot.name)}
|
|
177
300
|
/>
|
|
178
301
|
</label>
|
|
179
302
|
{/if}
|
|
@@ -184,11 +307,17 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
184
307
|
{@const items = slotItems(slot.name)}
|
|
185
308
|
{@const ids = slotIds(slot.name)}
|
|
186
309
|
<fieldset class="rounded-box border border-[var(--cairn-card-border)] flex flex-col gap-2 p-2">
|
|
187
|
-
<legend class="text-sm font-medium">{slot.label}</legend>
|
|
188
|
-
<!-- Keyed by the parallel stable id so a mid-list removal drops the right node and focus follows the data; the value still binds to the canonical items[i] string the serializer reads. -->
|
|
310
|
+
<legend class="text-sm font-medium">{slot.label}{#if slot.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</legend>
|
|
311
|
+
<!-- Keyed by the parallel stable id so a mid-list removal drops the right node and focus follows the data; the value still binds to the canonical items[i] string the serializer reads. The visible row tag derives from itemLabel, falling back to the indexed label. -->
|
|
189
312
|
{#each ids as id, i (id)}
|
|
313
|
+
{@const label = rowLabel(slot, items[i] ?? '', i)}
|
|
190
314
|
<div class="flex items-center gap-2">
|
|
191
|
-
<
|
|
315
|
+
<span class="flex-none text-xs text-[var(--color-muted)]">{label}</span>
|
|
316
|
+
<input
|
|
317
|
+
class="input input-sm flex-1"
|
|
318
|
+
aria-label={`${slot.label} ${i + 1}`}
|
|
319
|
+
bind:value={items[i]}
|
|
320
|
+
/>
|
|
192
321
|
<button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() => removeItem(slot.name, i)}>✕</button>
|
|
193
322
|
</div>
|
|
194
323
|
{/each}
|
|
@@ -197,5 +326,5 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
197
326
|
</fieldset>
|
|
198
327
|
{/each}
|
|
199
328
|
|
|
200
|
-
<button type="button" class="btn btn-primary btn-sm mt-2" onclick={submit}>
|
|
329
|
+
<button type="button" class="btn btn-primary btn-sm mt-2 self-start" disabled={incompleteState} onclick={submit}>{submitLabel}</button>
|
|
201
330
|
</div>
|
|
@@ -1,19 +1,33 @@
|
|
|
1
|
-
import { type ComponentDef } from '../render/registry.js';
|
|
1
|
+
import { type ComponentDef, type ComponentValues } from '../render/registry.js';
|
|
2
2
|
import type { IconSet } from '../render/glyph.js';
|
|
3
3
|
interface Props {
|
|
4
4
|
def: ComponentDef;
|
|
5
5
|
icons?: IconSet;
|
|
6
6
|
/** Called with the serialized markdown when the form validates. */
|
|
7
7
|
onInsert: (markdown: string) => void;
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/** The live working values, bound out so a host (the dialog) can render a preview from them. */
|
|
9
|
+
values?: ComponentValues;
|
|
10
|
+
/** True while a required attribute or slot is still empty, bound out so the host's preview can
|
|
11
|
+
* show the incomplete state and the host can mirror the disabled Insert. */
|
|
12
|
+
incomplete?: boolean;
|
|
13
|
+
/** Seed the working values from these instead of the schema's preview sample. The dialog passes
|
|
14
|
+
* it in edit mode to re-open a placed component into its own values; the catalog insert path
|
|
15
|
+
* leaves it unset and keeps the previewValues seed. */
|
|
16
|
+
initial?: ComponentValues;
|
|
17
|
+
/** The submit button's label. The dialog passes 'Update' in edit mode; the insert path keeps
|
|
18
|
+
* the default. */
|
|
19
|
+
submitLabel?: string;
|
|
10
20
|
}
|
|
11
21
|
/**
|
|
12
|
-
* The schema-driven fill form for one component
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
22
|
+
* The schema-driven fill form for one component, the left column of the configure step. It holds the
|
|
23
|
+
* working ComponentValues, seeded from previewValues(def) (the emptyValues base with any declared
|
|
24
|
+
* preview sample overlaid), and renders attribute fields and the title/body and other slots. Required
|
|
25
|
+
* fields carry an asterisk and aria-required, and Insert disables while any required field is empty.
|
|
26
|
+
* Submit serializes and validates through buildComponentInsert and calls onInsert with the markdown.
|
|
27
|
+
* This is not a nested HTML form; Insert calls a callback. The dialog owns the header (the Insert >
|
|
28
|
+
* group breadcrumb and the Back control) and, in the two-pane case, the preview pane; this component
|
|
29
|
+
* binds out its live `values` and `incomplete` so the dialog can render that preview.
|
|
16
30
|
*/
|
|
17
|
-
declare const ComponentForm: import("svelte").Component<Props, {}, "">;
|
|
31
|
+
declare const ComponentForm: import("svelte").Component<Props, {}, "incomplete" | "values">;
|
|
18
32
|
type ComponentForm = ReturnType<typeof ComponentForm>;
|
|
19
33
|
export default ComponentForm;
|