@glw907/cairn-cms 0.56.1 → 0.56.2

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.
@@ -8,19 +8,59 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
8
8
  <script module lang="ts">
9
9
  import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
10
10
 
11
- function hasSchema(def: ComponentDef): boolean {
11
+ /** Past this many actionable components, the catalog grows a search input. Below it search is
12
+ * noise over a list short enough to scan. */
13
+ const SEARCH_THRESHOLD = 8;
14
+
15
+ /** Whether a def carries a schema (any attribute or slot), so it opens the guided form rather
16
+ * than inserting a bare template. Exported so a host deciding on its own guided-edit affordance
17
+ * (the edit page's Edit-block control) reads the same notion the dialog lists and chooses by. */
18
+ export function hasSchema(def: ComponentDef): boolean {
12
19
  return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
13
20
  }
14
- /** The registry's actionable components: a schema opens the guided form, a template inserts
15
- * directly, and a component with neither is not listed. Exported so a host rendering its own
21
+ /** The registry's pickable components. A def is actionable when a schema opens the guided form or
22
+ * a template inserts directly; a def with neither is not listed. A `hidden` def is then dropped,
23
+ * so the hidden filter applies after the actionable one. Exported so a host rendering its own
16
24
  * trigger (the edit page's toolbar) can hide it under the same condition the dialog uses. */
17
25
  export function insertableDefs(registry?: ComponentRegistry): ComponentDef[] {
18
- return (registry?.defs ?? []).filter((def) => hasSchema(def) || Boolean(def.insertTemplate));
26
+ return (registry?.defs ?? []).filter(
27
+ (def) => (hasSchema(def) || Boolean(def.insertTemplate)) && !def.hidden,
28
+ );
29
+ }
30
+
31
+ /** A heading-bearing group of rows. A group with an empty heading renders without an eyebrow.
32
+ * Groups appear in first-declared order, and rows keep declaration order within each group. */
33
+ interface CatalogGroup {
34
+ heading: string;
35
+ defs: ComponentDef[];
36
+ }
37
+
38
+ /** Partition the defs into groups by `def.group`, preserving declaration order of both the
39
+ * groups (first time a group name is seen) and the rows within a group. A def with no `group`
40
+ * collects into one leading default group with no heading. */
41
+ function groupDefs(defs: ComponentDef[]): CatalogGroup[] {
42
+ const order: string[] = [];
43
+ const byHeading = new Map<string, ComponentDef[]>();
44
+ for (const def of defs) {
45
+ const heading = def.group ?? '';
46
+ if (!byHeading.has(heading)) {
47
+ byHeading.set(heading, []);
48
+ order.push(heading);
49
+ }
50
+ byHeading.get(heading)!.push(def);
51
+ }
52
+ return order.map((heading) => ({ heading, defs: byHeading.get(heading)! }));
19
53
  }
20
54
  </script>
21
55
 
22
56
  <script lang="ts">
57
+ import { tick } from 'svelte';
23
58
  import type { IconSet } from '../render/glyph.js';
59
+ import type { ComponentValues } from '../render/registry.js';
60
+ import type { ResolvedPreview } from '../content/types.js';
61
+ import type { LinkResolve } from '../content/links.js';
62
+ import { serializeComponent } from '../render/component-grammar.js';
63
+ import { buildPreviewDoc } from './preview-doc.js';
24
64
  import ComponentForm from './ComponentForm.svelte';
25
65
 
26
66
  interface Props {
@@ -28,8 +68,19 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
28
68
  registry?: ComponentRegistry;
29
69
  /** Insert markdown at the editor cursor. */
30
70
  insert: (text: string) => void;
71
+ /** Replace the placed component's source span with new markdown. Edit mode routes submit here
72
+ * instead of insert. Optional: a host that never opens edit mode passes none. */
73
+ update?: (range: { from: number; to: number }, markdown: string) => void;
31
74
  /** The site's icon set, for icon fields. */
32
75
  icons?: IconSet;
76
+ /** The site's design-accurate render pipeline. When present and the picked component declares a
77
+ * `preview`, the configure step splits to two panes and renders the configured directive
78
+ * through this into a sandboxed iframe (the same path EditPage's preview uses). Optional: a
79
+ * host that passes none simply gets no preview pane. */
80
+ render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
81
+ /** The adapter's resolved preview knob (stylesheets and container class), threaded to
82
+ * buildPreviewDoc so the preview frame links the site's own CSS, the same as EditPage. */
83
+ preview?: ResolvedPreview | null;
33
84
  /** Disable the trigger; the host sets it while Preview shows. */
34
85
  disabled?: boolean;
35
86
  /** Render the built-in Insert block trigger. False mounts only the dialog, for a host that
@@ -37,66 +88,368 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
37
88
  trigger?: boolean;
38
89
  }
39
90
 
40
- let { registry, insert, icons, disabled = false, trigger = true }: Props = $props();
91
+ let { registry, insert, update, icons, render, preview = null, disabled = false, trigger = true }: Props = $props();
41
92
 
42
93
  let dialog = $state<HTMLDialogElement | null>(null);
43
94
  let picked = $state<ComponentDef | null>(null);
95
+ let query = $state('');
96
+ let searchInput = $state<HTMLInputElement | null>(null);
97
+
98
+ // Edit mode re-opens a placed component into the same guided form. editValues seeds the form from
99
+ // the parsed block (not the catalog pick path), and editRange is the source span Update replaces.
100
+ // Both are null in the catalog insert flow. resetPreview clears them so a fresh open is clean.
101
+ let editValues = $state<ComponentValues | null>(null);
102
+ let editRange = $state<{ from: number; to: number } | null>(null);
103
+ const editing = $derived(editValues !== null);
104
+
105
+ // The form's live values and its required-empty state, bound out of ComponentForm so the preview
106
+ // pane can render from them and mirror the incomplete state.
107
+ let formValues = $state<ComponentValues | undefined>(undefined);
108
+ let formIncomplete = $state(false);
109
+
110
+ // Two-pane configure is opt-in: it appears only when the picked component declares a `preview`
111
+ // AND a render function is threaded. Otherwise the configure step stays single column.
112
+ const twoPane = $derived(Boolean(picked?.preview) && Boolean(render));
113
+
114
+ // The preview pane's settle state, the honest chip the mockup names. The empty/incomplete state
115
+ // wins over settling so the pane never claims to settle a fabricated block.
116
+ type PreviewState = 'settling' | 'settled' | 'failed';
117
+ let previewState = $state<PreviewState>('settled');
118
+ let previewDoc = $state('');
119
+
120
+ // The required regions left empty, named for the incomplete-state callout. A boolean attribute is
121
+ // always met; a text/select/icon attribute or a slot is unmet when empty.
122
+ const emptyRequired = $derived.by(() => {
123
+ if (!picked || !formValues) return [] as string[];
124
+ const out: string[] = [];
125
+ for (const field of picked.attributes ?? []) {
126
+ if (!field.required || field.type === 'boolean') continue;
127
+ const v = formValues.attributes[field.key];
128
+ if (typeof v !== 'string' || v === '') out.push(field.label);
129
+ }
130
+ for (const slot of picked.slots ?? []) {
131
+ if (!slot.required) continue;
132
+ const v = formValues.slots[slot.name];
133
+ const filled = Array.isArray(v) ? v.some((i) => i !== '') : typeof v === 'string' && v !== '';
134
+ if (!filled) out.push(slot.label);
135
+ }
136
+ return out;
137
+ });
138
+
139
+ // The debounced, latest-wins preview render, the same shape EditPage's preview effect uses: a
140
+ // setTimeout debounce (~200ms) guarded by a plain counter so a slow earlier render that resolves
141
+ // after a newer one started is discarded, one persistent iframe whose srcdoc is replaced. The
142
+ // incomplete state short-circuits the render (the skeleton renders from the template, not the
143
+ // pipeline), so a required-empty block never reaches the site render as a fabricated finish.
144
+ let previewRun = 0;
145
+ $effect(() => {
146
+ if (!twoPane || !render || !picked || !formValues) return;
147
+ if (formIncomplete) {
148
+ previewState = 'settled';
149
+ previewRun++;
150
+ return;
151
+ }
152
+ const md = serializeComponent(picked, formValues);
153
+ const run = ++previewRun;
154
+ previewState = 'settling';
155
+ const handle = setTimeout(async () => {
156
+ try {
157
+ const html = await render(md);
158
+ if (run === previewRun) {
159
+ previewDoc = buildPreviewDoc(html, preview);
160
+ previewState = 'settled';
161
+ }
162
+ } catch {
163
+ if (run === previewRun) {
164
+ previewState = 'failed';
165
+ }
166
+ }
167
+ }, 200);
168
+ return () => {
169
+ clearTimeout(handle);
170
+ previewRun++;
171
+ };
172
+ });
44
173
 
45
174
  const defs = $derived(insertableDefs(registry));
175
+ /** The catalog grows a search input only once the actionable count crosses the threshold. */
176
+ const showSearch = $derived(defs.length > SEARCH_THRESHOLD);
177
+ /** The defs matching the live query, by label or description (case-insensitive). With no query
178
+ * the whole catalog shows. */
179
+ const filtered = $derived.by(() => {
180
+ const q = query.trim().toLowerCase();
181
+ if (!q) return defs;
182
+ return defs.filter(
183
+ (def) =>
184
+ def.label.toLowerCase().includes(q) || (def.description ?? '').toLowerCase().includes(q),
185
+ );
186
+ });
187
+ const groups = $derived(groupDefs(filtered));
188
+
189
+ /** Clear the four preview-coupled state cells so no stale preview HTML or settle state survives a
190
+ * re-open, a step back, or a fresh pick. Called from every transition that leaves the configure
191
+ * step or starts a new one. */
192
+ function resetPreview() {
193
+ formValues = undefined;
194
+ formIncomplete = false;
195
+ previewState = 'settled';
196
+ previewDoc = '';
197
+ editValues = null;
198
+ editRange = null;
199
+ }
46
200
 
47
201
  /** Open the picker. Exported so a trigger={false} host can drive the dialog itself. */
48
202
  export function open() {
49
203
  picked = null;
204
+ query = '';
205
+ resetPreview();
206
+ dialog?.showModal();
207
+ // Focus the search box on open when it shows, so an editor with a large catalog types straight
208
+ // into the filter. The dialog's own focus trap already lands focus on the first row otherwise.
209
+ if (showSearch) {
210
+ void tick().then(() => searchInput?.focus());
211
+ }
212
+ }
213
+ /** Re-open a placed component into the same guided form. Skips the catalog: seeds the form from
214
+ * the parsed values, stores the source range for Update, and shows the configure step straight
215
+ * away. Exported so the host (the edit page's Edit-block control) drives it. */
216
+ export function editComponent(
217
+ def: ComponentDef,
218
+ values: ComponentValues,
219
+ range: { from: number; to: number },
220
+ ) {
221
+ resetPreview();
222
+ query = '';
223
+ editValues = values;
224
+ editRange = range;
225
+ picked = def;
50
226
  dialog?.showModal();
51
227
  }
228
+
229
+ function back() {
230
+ picked = null;
231
+ resetPreview();
232
+ }
52
233
  function close() {
53
234
  picked = null;
54
235
  dialog?.close();
55
236
  }
237
+ function onClose() {
238
+ picked = null;
239
+ resetPreview();
240
+ }
56
241
  function choose(def: ComponentDef) {
57
242
  if (hasSchema(def)) {
243
+ resetPreview();
58
244
  picked = def;
245
+ // ComponentForm focuses its own first field on mount.
59
246
  } else {
60
247
  insert(def.insertTemplate ?? '');
61
248
  close();
62
249
  }
63
250
  }
64
- function onInsert(markdown: string) {
65
- insert(markdown);
251
+ // The form's submit routes here. In edit mode it replaces the stored source span through update;
252
+ // otherwise it inserts at the cursor. Either way the dialog closes.
253
+ function onSubmit(markdown: string) {
254
+ if (editing && editRange) {
255
+ update?.(editRange, markdown);
256
+ } else {
257
+ insert(markdown);
258
+ }
66
259
  close();
67
260
  }
261
+
262
+ // The native <dialog> turns Escape into a close. At the configure step Escape should step back to
263
+ // the catalog instead (one level), matching the catalog's own Back control; from the catalog it
264
+ // closes. Handling cancel (the event the dialog fires on Escape) lets us preventDefault and
265
+ // intercept the first level.
266
+ function onCancel(e: Event) {
267
+ // In edit mode there is no catalog to step back to, so Escape closes (the default). At the
268
+ // configure step of the insert flow, intercept the first Escape and step back to the catalog.
269
+ if (picked && !editing) {
270
+ e.preventDefault();
271
+ back();
272
+ }
273
+ }
274
+
275
+ // Arrow keys roam the rows: each row is a real <button>, so Enter chooses for free and Escape
276
+ // closes through the native <dialog>. Up/Down (and Left/Right) move focus between the row buttons
277
+ // in DOM order across every group, wrapping at the ends, the list-navigation model an editor
278
+ // expects. The handler sits on each row button (an interactive element), and finds its siblings
279
+ // through the shared scroll region so navigation crosses group boundaries.
280
+ function onRowKeydown(e: KeyboardEvent) {
281
+ const isNext = e.key === 'ArrowDown' || e.key === 'ArrowRight';
282
+ const isPrev = e.key === 'ArrowUp' || e.key === 'ArrowLeft';
283
+ if (!isNext && !isPrev) return;
284
+ const region = (e.currentTarget as HTMLElement).closest('[data-cairn-pk-list]');
285
+ if (!region) return;
286
+ const rows = [...region.querySelectorAll<HTMLButtonElement>('[data-testid="cairn-pk-row"]')];
287
+ const from = rows.indexOf(e.currentTarget as HTMLButtonElement);
288
+ if (from < 0) return;
289
+ e.preventDefault();
290
+ const next = (from + (isNext ? 1 : -1) + rows.length) % rows.length;
291
+ rows[next].focus();
292
+ }
293
+
294
+ // ArrowDown from the search input enters the list at the first row, ArrowUp at the last, so the
295
+ // keyboard model is whole: type to filter, then arrow into the results without reaching for the
296
+ // mouse. From a row, onRowKeydown takes over.
297
+ function onSearchKeydown(e: KeyboardEvent) {
298
+ const isNext = e.key === 'ArrowDown';
299
+ const isPrev = e.key === 'ArrowUp';
300
+ if (!isNext && !isPrev) return;
301
+ const rows = dialog?.querySelectorAll<HTMLButtonElement>('[data-testid="cairn-pk-row"]');
302
+ if (!rows || rows.length === 0) return;
303
+ e.preventDefault();
304
+ rows[isNext ? 0 : rows.length - 1].focus();
305
+ }
68
306
  </script>
69
307
 
308
+ <!-- The guided form, identical in either layout: the two-pane case wraps it in the left column, the
309
+ single-column case renders it bare. One snippet keeps the prop wiring in one place. -->
310
+ {#snippet configureForm(def: ComponentDef)}
311
+ <ComponentForm {def} {icons} onInsert={onSubmit} initial={editValues ?? undefined} submitLabel={editing ? 'Update' : 'Insert'} bind:values={formValues} bind:incomplete={formIncomplete} />
312
+ {/snippet}
313
+
70
314
  {#if trigger && defs.length > 0}
71
315
  <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Insert block" {disabled} onclick={open}>Insert block</button>
72
316
  {/if}
73
317
 
74
318
  {#if defs.length > 0}
75
- <dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={() => (picked = null)}>
76
- <div class="modal-box">
77
- <div class="mb-3 flex items-center justify-between">
78
- <h2 id="cairn-insert-dialog-title" class="text-base font-semibold">Insert component</h2>
79
- <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
319
+ <dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={onClose} oncancel={onCancel}>
320
+ <div class="modal-box {twoPane ? 'max-w-3xl' : ''}">
321
+ <!-- The shared header: at the configure step it carries the Back control and the
322
+ "Insert > group" eyebrow breadcrumb above the component label; while browsing it is the
323
+ plain "Insert a component" title. -->
324
+ <div class="mb-3 flex items-center gap-3">
325
+ {#if picked && !editing}
326
+ <button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Back to components" onclick={back}>
327
+ <svg class="h-4 w-4" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M165.7 202.3a8 8 0 0 1-11.4 11.4l-80-80a8 8 0 0 1 0-11.4l80-80a8 8 0 0 1 11.4 11.4L91.3 128Z" /></svg>
328
+ </button>
329
+ {/if}
330
+ <div class="min-w-0 flex-1">
331
+ {#if picked}
332
+ <div class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">{editing ? 'Edit' : 'Insert'}{#if picked.group}&nbsp;&rsaquo;&nbsp;{picked.group}{/if}</div>
333
+ <h2 id="cairn-insert-dialog-title" class="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight">{picked.label}</h2>
334
+ {:else}
335
+ <h2 id="cairn-insert-dialog-title" class="text-base font-semibold">Insert a component</h2>
336
+ {/if}
337
+ </div>
338
+ <button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Close" onclick={close}>✕</button>
80
339
  </div>
81
340
 
82
341
  {#if picked}
83
342
  {#key picked}
84
- <ComponentForm def={picked} {icons} {onInsert} onBack={() => (picked = null)} />
343
+ {#if twoPane}
344
+ <!-- Two panes: the form on the left, the live preview on the right. Below the breakpoint
345
+ the preview stacks beneath the form. -->
346
+ <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
347
+ <div class="overflow-auto">
348
+ {@render configureForm(picked)}
349
+ </div>
350
+ <div data-testid="cairn-pk-preview" class="flex flex-col gap-2 rounded-box border border-[var(--cairn-card-border)] bg-base-200 p-3">
351
+ <div class="flex items-baseline justify-between gap-2">
352
+ <span class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">Preview</span>
353
+ <!-- A silent visual cue, never an announcement: it re-rendered on every debounced
354
+ keystroke, so a screen reader read "Settling"/"Settled" aloud on each edit. The
355
+ incomplete and render-failed conditions reach assistive tech through the
356
+ per-field role="alert" errors and the failed panel text, so nothing is lost. -->
357
+ <span data-testid="cairn-pk-settle" class="inline-flex items-center gap-1.5 text-[0.7rem] text-[var(--color-muted)]">
358
+ {#if formIncomplete}
359
+ Incomplete
360
+ {:else if previewState === 'failed'}
361
+ Could not render
362
+ {:else if previewState === 'settling'}
363
+ <span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none" aria-hidden="true"></span>
364
+ Settling
365
+ {:else}
366
+ Settled
367
+ {/if}
368
+ </span>
369
+ </div>
370
+ {#if formIncomplete}
371
+ <!-- The skeleton: never a fabricated finished block. The empty required regions are
372
+ called out by name so the editor knows exactly what the preview still needs. -->
373
+ <div class="flex flex-1 flex-col items-center justify-center gap-2 rounded-box border border-dashed border-[var(--cairn-card-border)] p-6 text-center">
374
+ <p class="text-sm font-medium">Fill the required fields to preview this.</p>
375
+ <p class="flex flex-wrap justify-center gap-1.5 text-xs">
376
+ {#each emptyRequired as label (label)}
377
+ <span class="rounded border border-dashed border-[color-mix(in_oklab,var(--color-error)_55%,var(--cairn-card-border))] px-2 py-0.5 font-medium text-error">{label} needed</span>
378
+ {/each}
379
+ </p>
380
+ </div>
381
+ {:else if previewState === 'failed'}
382
+ <!-- The render threw. Say so and keep the form intact; the editor can still insert. -->
383
+ <div data-testid="cairn-pk-preview-failed" class="flex flex-1 flex-col items-center justify-center gap-1.5 rounded-box border border-[color-mix(in_oklab,var(--color-error)_35%,var(--cairn-card-border))] bg-[color-mix(in_oklab,var(--color-error)_5%,transparent)] p-5 text-center text-error">
384
+ <p class="text-sm font-semibold">Preview could not render</p>
385
+ <p class="text-xs text-[var(--color-muted)]">Your settings are kept. You can still insert and check it on the page.</p>
386
+ </div>
387
+ {:else}
388
+ <div class="flex min-h-64 flex-1 overflow-hidden rounded-box border border-[var(--cairn-card-border)] bg-base-100 shadow-[var(--cairn-shadow)]">
389
+ <iframe sandbox="" title="Component preview" srcdoc={previewDoc} class="block w-full flex-1"></iframe>
390
+ </div>
391
+ {/if}
392
+ </div>
393
+ </div>
394
+ {:else}
395
+ {@render configureForm(picked)}
396
+ {/if}
85
397
  {/key}
86
398
  {:else}
87
- <ul class="menu w-full">
88
- {#each defs as def (def.name)}
89
- <li>
90
- <button type="button" onclick={() => choose(def)}>
91
- <span class="flex flex-col items-start">
92
- <span class="font-medium">{def.label}</span>
93
- {#if def.description}<span class="text-xs text-[var(--color-muted)]">{def.description}</span>{/if}
94
- {#if def.use}<span class="text-xs text-[var(--color-muted)]">{def.use}</span>{/if}
95
- </span>
96
- </button>
97
- </li>
98
- {/each}
99
- </ul>
399
+ {#if showSearch}
400
+ <div class="mb-3 flex items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
401
+ <svg class="ec-glyph h-4 w-4 text-[var(--color-muted)]" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M229.7 218.3 179.6 168.2A92.2 92.2 0 1 0 168.2 179.6l50.1 50.1a8 8 0 0 0 11.4-11.4ZM40 112a72 72 0 1 1 72 72 72.1 72.1 0 0 1-72-72Z" /></svg>
402
+ <input
403
+ type="search"
404
+ class="w-full border-0 bg-transparent p-0 text-sm outline-hidden placeholder:text-[var(--color-muted)]"
405
+ placeholder="Search components"
406
+ aria-label="Search components"
407
+ bind:value={query}
408
+ bind:this={searchInput}
409
+ onkeydown={onSearchKeydown}
410
+ />
411
+ </div>
412
+ <p class="sr-only" role="status" aria-live="polite">
413
+ {filtered.length} {filtered.length === 1 ? 'component' : 'components'} match
414
+ </p>
415
+ {/if}
416
+
417
+ {#if filtered.length === 0}
418
+ <!-- The query matched nothing. The components exist; none match. Offer the way back. -->
419
+ <div class="flex flex-col items-center gap-3 px-6 py-12 text-center">
420
+ <p class="text-sm text-[var(--color-muted)]">No components match <span class="font-medium text-base-content">"{query.trim()}"</span>.</p>
421
+ <button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={() => (query = '')}>Clear search</button>
422
+ </div>
423
+ {:else}
424
+ <!-- One scroll region holds every group, so the arrow keys roam the whole catalog. -->
425
+ <div data-cairn-pk-list>
426
+ {#each groups as group (group.heading)}
427
+ <div class="mt-3 first:mt-0">
428
+ {#if group.heading}
429
+ <div data-testid="cairn-pk-group-heading" class="px-2 pb-1.5 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">{group.heading}</div>
430
+ {/if}
431
+ <ul class="menu w-full p-0">
432
+ {#each group.defs as def (def.name)}
433
+ <li>
434
+ <button type="button" data-testid="cairn-pk-row" class="flex items-start gap-3 py-2" onclick={() => choose(def)} onkeydown={onRowKeydown}>
435
+ {#if def.icon && icons?.[def.icon]}
436
+ <span class="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-base-200 text-base-content">
437
+ <svg class="ec-glyph h-4 w-4" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d={icons[def.icon]} /></svg>
438
+ </span>
439
+ {/if}
440
+ <span class="flex flex-col items-start gap-0.5">
441
+ <span data-testid="cairn-pk-row-label" class="text-sm font-medium">{def.label}</span>
442
+ {#if def.description}<span class="text-xs text-[var(--color-muted)]">{def.description}</span>{/if}
443
+ {#if def.use}<span class="text-xs text-[var(--color-subtle)]">{def.use}</span>{/if}
444
+ </span>
445
+ </button>
446
+ </li>
447
+ {/each}
448
+ </ul>
449
+ </div>
450
+ {/each}
451
+ </div>
452
+ {/if}
100
453
  {/if}
101
454
  </div>
102
455
  <form method="dialog" class="modal-backdrop">
@@ -1,16 +1,41 @@
1
1
  import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
2
- /** The registry's actionable components: a schema opens the guided form, a template inserts
3
- * directly, and a component with neither is not listed. Exported so a host rendering its own
2
+ /** Whether a def carries a schema (any attribute or slot), so it opens the guided form rather
3
+ * than inserting a bare template. Exported so a host deciding on its own guided-edit affordance
4
+ * (the edit page's Edit-block control) reads the same notion the dialog lists and chooses by. */
5
+ export declare function hasSchema(def: ComponentDef): boolean;
6
+ /** The registry's pickable components. A def is actionable when a schema opens the guided form or
7
+ * a template inserts directly; a def with neither is not listed. A `hidden` def is then dropped,
8
+ * so the hidden filter applies after the actionable one. Exported so a host rendering its own
4
9
  * trigger (the edit page's toolbar) can hide it under the same condition the dialog uses. */
5
10
  export declare function insertableDefs(registry?: ComponentRegistry): ComponentDef[];
6
11
  import type { IconSet } from '../render/glyph.js';
12
+ import type { ComponentValues } from '../render/registry.js';
13
+ import type { ResolvedPreview } from '../content/types.js';
14
+ import type { LinkResolve } from '../content/links.js';
7
15
  interface Props {
8
16
  /** The site's component registry. */
9
17
  registry?: ComponentRegistry;
10
18
  /** Insert markdown at the editor cursor. */
11
19
  insert: (text: string) => void;
20
+ /** Replace the placed component's source span with new markdown. Edit mode routes submit here
21
+ * instead of insert. Optional: a host that never opens edit mode passes none. */
22
+ update?: (range: {
23
+ from: number;
24
+ to: number;
25
+ }, markdown: string) => void;
12
26
  /** The site's icon set, for icon fields. */
13
27
  icons?: IconSet;
28
+ /** The site's design-accurate render pipeline. When present and the picked component declares a
29
+ * `preview`, the configure step splits to two panes and renders the configured directive
30
+ * through this into a sandboxed iframe (the same path EditPage's preview uses). Optional: a
31
+ * host that passes none simply gets no preview pane. */
32
+ render?: (md: string, opts?: {
33
+ stagger?: boolean;
34
+ resolve?: LinkResolve;
35
+ }) => string | Promise<string>;
36
+ /** The adapter's resolved preview knob (stylesheets and container class), threaded to
37
+ * buildPreviewDoc so the preview frame links the site's own CSS, the same as EditPage. */
38
+ preview?: ResolvedPreview | null;
14
39
  /** Disable the trigger; the host sets it while Preview shows. */
15
40
  disabled?: boolean;
16
41
  /** Render the built-in Insert block trigger. False mounts only the dialog, for a host that
@@ -25,6 +50,10 @@ interface Props {
25
50
  */
26
51
  declare const ComponentInsertDialog: import("svelte").Component<Props, {
27
52
  open: () => void;
53
+ editComponent: (def: ComponentDef, values: ComponentValues, range: {
54
+ from: number;
55
+ to: number;
56
+ }) => void;
28
57
  }, "">;
29
58
  type ComponentInsertDialog = ReturnType<typeof ComponentInsertDialog>;
30
59
  export default ComponentInsertDialog;