@glw907/cairn-cms 0.62.2 → 0.76.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 +216 -0
- package/dist/ambient.d.ts +2 -0
- package/dist/auth/types.d.ts +7 -0
- package/dist/components/CairnAdmin.svelte.d.ts +2 -7
- package/dist/components/ComponentForm.svelte +44 -27
- package/dist/components/ComponentInsertDialog.svelte +22 -11
- package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
- package/dist/components/ConceptList.svelte +25 -4
- package/dist/components/EditPage.svelte +29 -107
- package/dist/components/EditPage.svelte.d.ts +2 -7
- package/dist/components/EntryPicker.svelte +117 -0
- package/dist/components/EntryPicker.svelte.d.ts +35 -0
- package/dist/components/FieldInput.svelte +218 -0
- package/dist/components/FieldInput.svelte.d.ts +51 -0
- package/dist/components/IconPicker.svelte +2 -2
- package/dist/components/IconPicker.svelte.d.ts +2 -0
- package/dist/components/LinkPicker.svelte +8 -75
- package/dist/components/LinkPicker.svelte.d.ts +4 -5
- package/dist/components/MediaHeroField.svelte +8 -5
- package/dist/components/MediaHeroField.svelte.d.ts +4 -0
- package/dist/components/ObjectGroupField.svelte +54 -0
- package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
- package/dist/components/ReferenceField.svelte +94 -0
- package/dist/components/ReferenceField.svelte.d.ts +27 -0
- package/dist/components/RepeatableField.svelte +221 -0
- package/dist/components/RepeatableField.svelte.d.ts +53 -0
- package/dist/components/cairn-admin.css +179 -2
- package/dist/components/preview-doc.js +5 -1
- package/dist/components/tidy-validate.js +1 -1
- package/dist/content/adapter.js +18 -0
- package/dist/content/advisories.d.ts +2 -2
- package/dist/content/advisories.js +3 -5
- package/dist/content/compose.d.ts +7 -6
- package/dist/content/compose.js +26 -20
- package/dist/content/concepts.d.ts +21 -15
- package/dist/content/concepts.js +55 -32
- package/dist/content/field-rules.d.ts +15 -0
- package/dist/content/field-rules.js +38 -0
- package/dist/content/fields.d.ts +169 -0
- package/dist/content/fields.js +41 -0
- package/dist/content/fieldset.d.ts +107 -0
- package/dist/content/fieldset.js +386 -0
- package/dist/content/frontmatter-region.d.ts +38 -0
- package/dist/content/frontmatter-region.js +75 -0
- package/dist/content/frontmatter.d.ts +35 -2
- package/dist/content/frontmatter.js +232 -11
- package/dist/content/manifest.d.ts +34 -0
- package/dist/content/manifest.js +80 -4
- package/dist/content/media-refs.d.ts +2 -2
- package/dist/content/media-rewrite.js +1 -69
- package/dist/content/reference-index.d.ts +56 -0
- package/dist/content/reference-index.js +95 -0
- package/dist/content/references.d.ts +40 -0
- package/dist/content/references.js +0 -0
- package/dist/content/standard-schema.d.ts +30 -0
- package/dist/content/standard-schema.js +4 -0
- package/dist/content/types.d.ts +127 -178
- package/dist/delivery/data.d.ts +2 -2
- package/dist/delivery/data.js +1 -1
- package/dist/delivery/public-routes.d.ts +10 -5
- package/dist/delivery/public-routes.js +25 -2
- package/dist/delivery/site-descriptors.d.ts +5 -1
- package/dist/delivery/site-descriptors.js +8 -3
- package/dist/delivery/site-indexes.d.ts +2 -2
- package/dist/delivery/site-resolver.d.ts +25 -0
- package/dist/delivery/site-resolver.js +49 -0
- package/dist/doctor/checks-local.js +6 -11
- package/dist/github/backend.d.ts +83 -0
- package/dist/github/backend.js +76 -0
- package/dist/github/credentials.d.ts +11 -5
- package/dist/github/credentials.js +3 -3
- package/dist/github/repo.d.ts +8 -19
- package/dist/github/repo.js +69 -80
- package/dist/github/types.d.ts +1 -1
- package/dist/github/types.js +4 -4
- package/dist/index.d.ts +18 -10
- package/dist/index.js +9 -5
- package/dist/islands/index.d.ts +12 -0
- package/dist/islands/index.js +83 -0
- package/dist/islands/types.d.ts +7 -0
- package/dist/islands/types.js +1 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/manifest.d.ts +11 -0
- package/dist/media/manifest.js +13 -0
- package/dist/media/rewrite-plan.d.ts +2 -3
- package/dist/media/rewrite-plan.js +2 -3
- package/dist/media/usage.d.ts +2 -2
- package/dist/media/usage.js +3 -5
- package/dist/nav/site-config.d.ts +0 -6
- package/dist/nav/site-config.js +6 -4
- package/dist/render/component-grammar.js +11 -11
- package/dist/render/component-reference.js +5 -3
- package/dist/render/component-validate.d.ts +4 -1
- package/dist/render/component-validate.js +10 -35
- package/dist/render/highlight.d.ts +9 -0
- package/dist/render/highlight.js +206 -0
- package/dist/render/pipeline.d.ts +0 -6
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +44 -36
- package/dist/render/registry.js +47 -6
- package/dist/render/rehype-dispatch.d.ts +6 -10
- package/dist/render/rehype-dispatch.js +38 -17
- package/dist/render/remark-directives.js +4 -5
- package/dist/render/sanitize-schema.d.ts +10 -0
- package/dist/render/sanitize-schema.js +30 -1
- package/dist/sveltekit/cairn-admin.d.ts +5 -5
- package/dist/sveltekit/cairn-admin.js +3 -4
- package/dist/sveltekit/content-routes.d.ts +10 -8
- package/dist/sveltekit/content-routes.js +269 -181
- package/dist/sveltekit/guard.js +10 -0
- package/dist/sveltekit/health.d.ts +7 -3
- package/dist/sveltekit/health.js +9 -3
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/nav-routes.d.ts +6 -5
- package/dist/sveltekit/nav-routes.js +22 -20
- package/dist/sveltekit/types.d.ts +2 -0
- package/dist/vite/index.d.ts +3 -3
- package/dist/vite/index.js +17 -8
- package/package.json +17 -2
- package/src/lib/ambient.ts +7 -0
- package/src/lib/auth/types.ts +7 -0
- package/src/lib/components/CairnAdmin.svelte +2 -6
- package/src/lib/components/ComponentForm.svelte +48 -27
- package/src/lib/components/ComponentInsertDialog.svelte +26 -14
- package/src/lib/components/ConceptList.svelte +41 -4
- package/src/lib/components/EditPage.svelte +43 -119
- package/src/lib/components/EntryPicker.svelte +154 -0
- package/src/lib/components/FieldInput.svelte +262 -0
- package/src/lib/components/IconPicker.svelte +4 -2
- package/src/lib/components/LinkPicker.svelte +10 -81
- package/src/lib/components/MediaHeroField.svelte +12 -5
- package/src/lib/components/ObjectGroupField.svelte +97 -0
- package/src/lib/components/ReferenceField.svelte +126 -0
- package/src/lib/components/RepeatableField.svelte +310 -0
- package/src/lib/components/preview-doc.ts +5 -1
- package/src/lib/components/tidy-validate.ts +1 -1
- package/src/lib/content/adapter.ts +21 -0
- package/src/lib/content/advisories.ts +4 -7
- package/src/lib/content/compose.ts +30 -23
- package/src/lib/content/concepts.ts +68 -40
- package/src/lib/content/field-rules.ts +39 -0
- package/src/lib/content/fields.ts +178 -0
- package/src/lib/content/fieldset.ts +470 -0
- package/src/lib/content/frontmatter-region.ts +90 -0
- package/src/lib/content/frontmatter.ts +231 -15
- package/src/lib/content/manifest.ts +101 -4
- package/src/lib/content/media-refs.ts +2 -2
- package/src/lib/content/media-rewrite.ts +7 -80
- package/src/lib/content/reference-index.ts +159 -0
- package/src/lib/content/references.ts +0 -0
- package/src/lib/content/standard-schema.ts +25 -0
- package/src/lib/content/types.ts +128 -195
- package/src/lib/delivery/data.ts +2 -2
- package/src/lib/delivery/public-routes.ts +36 -4
- package/src/lib/delivery/site-descriptors.ts +8 -3
- package/src/lib/delivery/site-indexes.ts +2 -2
- package/src/lib/delivery/site-resolver.ts +64 -0
- package/src/lib/doctor/checks-local.ts +6 -14
- package/src/lib/github/backend.ts +161 -0
- package/src/lib/github/credentials.ts +10 -7
- package/src/lib/github/repo.ts +79 -83
- package/src/lib/github/types.ts +5 -5
- package/src/lib/index.ts +40 -18
- package/src/lib/islands/index.ts +84 -0
- package/src/lib/islands/types.ts +11 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/media/index.ts +1 -0
- package/src/lib/media/manifest.ts +14 -0
- package/src/lib/media/rewrite-plan.ts +4 -6
- package/src/lib/media/usage.ts +4 -7
- package/src/lib/nav/site-config.ts +8 -9
- package/src/lib/render/component-grammar.ts +10 -10
- package/src/lib/render/component-reference.ts +4 -3
- package/src/lib/render/component-validate.ts +10 -35
- package/src/lib/render/highlight.ts +259 -0
- package/src/lib/render/pipeline.ts +13 -8
- package/src/lib/render/registry.ts +88 -42
- package/src/lib/render/rehype-dispatch.ts +47 -16
- package/src/lib/render/remark-directives.ts +4 -5
- package/src/lib/render/sanitize-schema.ts +32 -1
- package/src/lib/sveltekit/cairn-admin.ts +8 -9
- package/src/lib/sveltekit/content-routes.ts +330 -221
- package/src/lib/sveltekit/guard.ts +15 -0
- package/src/lib/sveltekit/health.ts +13 -6
- package/src/lib/sveltekit/index.ts +2 -2
- package/src/lib/sveltekit/nav-routes.ts +33 -29
- package/src/lib/sveltekit/types.ts +5 -1
- package/src/lib/vite/index.ts +20 -11
- package/dist/content/schema.d.ts +0 -87
- package/dist/content/schema.js +0 -89
- package/dist/content/validate.d.ts +0 -17
- package/dist/content/validate.js +0 -93
- package/src/lib/content/schema.ts +0 -167
- package/src/lib/content/validate.ts +0 -90
|
@@ -16,7 +16,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
16
16
|
* than inserting a bare template. Exported so a host deciding on its own guided-edit affordance
|
|
17
17
|
* (the edit page's Edit-block control) reads the same notion the dialog lists and chooses by. */
|
|
18
18
|
export function hasSchema(def: ComponentDef): boolean {
|
|
19
|
-
return (def.attributes
|
|
19
|
+
return Object.keys(def.attributes ?? {}).length > 0 || (def.slots?.length ?? 0) > 0;
|
|
20
20
|
}
|
|
21
21
|
/** The registry's pickable components. A def is actionable when a schema opens the guided form or
|
|
22
22
|
* a template inserts directly; a def with neither is not listed. A `hidden` def is then dropped,
|
|
@@ -57,8 +57,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
57
57
|
import { tick } from 'svelte';
|
|
58
58
|
import type { IconSet } from '../render/glyph.js';
|
|
59
59
|
import type { ComponentValues } from '../render/registry.js';
|
|
60
|
-
import type { ResolvedPreview } from '../content/types.js';
|
|
61
|
-
import type { LinkResolve } from '../content/links.js';
|
|
60
|
+
import type { ResolvedPreview, SiteRender } from '../content/types.js';
|
|
62
61
|
import { serializeComponent } from '../render/component-grammar.js';
|
|
63
62
|
import { buildPreviewDoc } from './preview-doc.js';
|
|
64
63
|
import ComponentForm from './ComponentForm.svelte';
|
|
@@ -77,7 +76,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
77
76
|
* `preview`, the configure step splits to two panes and renders the configured directive
|
|
78
77
|
* through this into a sandboxed iframe (the same path EditPage's preview uses). Optional: a
|
|
79
78
|
* host that passes none simply gets no preview pane. */
|
|
80
|
-
render?:
|
|
79
|
+
render?: SiteRender;
|
|
81
80
|
/** The adapter's resolved preview knob (stylesheets and container class), threaded to
|
|
82
81
|
* buildPreviewDoc so the preview frame links the site's own CSS, the same as EditPage. */
|
|
83
82
|
preview?: ResolvedPreview | null;
|
|
@@ -122,10 +121,12 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
122
121
|
const emptyRequired = $derived.by(() => {
|
|
123
122
|
if (!picked || !formValues) return [] as string[];
|
|
124
123
|
const out: string[] = [];
|
|
125
|
-
for (const field of picked.attributes ??
|
|
124
|
+
for (const [name, field] of Object.entries(picked.attributes ?? {})) {
|
|
126
125
|
if (!field.required || field.type === 'boolean') continue;
|
|
127
|
-
const v = formValues.attributes[
|
|
128
|
-
|
|
126
|
+
const v = formValues.attributes[name];
|
|
127
|
+
// A scalar attribute always carries a label; the `?? name` only satisfies the union type, whose
|
|
128
|
+
// object/array members have an optional label that checkComponentAttributes already rejects.
|
|
129
|
+
if (typeof v !== 'string' || v === '') out.push(field.label ?? name);
|
|
129
130
|
}
|
|
130
131
|
for (const slot of picked.slots ?? []) {
|
|
131
132
|
if (!slot.required) continue;
|
|
@@ -154,7 +155,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
154
155
|
previewState = 'settling';
|
|
155
156
|
const handle = setTimeout(async () => {
|
|
156
157
|
try {
|
|
157
|
-
const html = await render(md);
|
|
158
|
+
const html = await render({ body: md });
|
|
158
159
|
if (run === previewRun) {
|
|
159
160
|
previewDoc = buildPreviewDoc(html, preview);
|
|
160
161
|
previewState = 'settled';
|
|
@@ -317,11 +318,16 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
317
318
|
|
|
318
319
|
{#if defs.length > 0}
|
|
319
320
|
<dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={onClose} oncancel={onCancel}>
|
|
320
|
-
|
|
321
|
+
<!-- The box caps at 85vh and is a flex column so its header holds while only the body scrolls,
|
|
322
|
+
per the design system's dialog-sizing recipe. The cap rides Tailwind utilities (the utilities
|
|
323
|
+
layer) so it beats DaisyUI's `.modal-box` max-height: 100vh; a components-layer rule loses the
|
|
324
|
+
cascade. overflow-hidden keeps the box from being a second scroll container. Matches TidyReview. -->
|
|
325
|
+
<div class="modal-box flex max-h-[85vh] flex-col overflow-hidden {twoPane ? 'max-w-3xl' : ''}">
|
|
321
326
|
<!-- The shared header: at the configure step it carries the Back control and the
|
|
322
327
|
"Insert > group" eyebrow breadcrumb above the component label; while browsing it is the
|
|
323
|
-
plain "Insert a component" title.
|
|
324
|
-
|
|
328
|
+
plain "Insert a component" title. It holds (flex-none) while the body scrolls, per the
|
|
329
|
+
design system's dialog-sizing recipe. -->
|
|
330
|
+
<div class="mb-3 flex flex-none items-center gap-3">
|
|
325
331
|
{#if picked && !editing}
|
|
326
332
|
<button type="button" class="btn btn-ghost btn-sm btn-square" aria-label="Back to components" onclick={back}>
|
|
327
333
|
<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>
|
|
@@ -339,6 +345,9 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
339
345
|
</div>
|
|
340
346
|
|
|
341
347
|
{#if picked}
|
|
348
|
+
<!-- The configure body is the box's scroll container (flex-1, min-h-0): the shared header
|
|
349
|
+
above holds while the form scrolls within the 85vh cap. -->
|
|
350
|
+
<div class="-mr-1 flex min-h-0 flex-1 flex-col overflow-y-auto pr-1">
|
|
342
351
|
{#key picked}
|
|
343
352
|
{#if twoPane}
|
|
344
353
|
<!-- Two panes: the form on the left, the live preview on the right. Below the breakpoint
|
|
@@ -395,9 +404,10 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
395
404
|
{@render configureForm(picked)}
|
|
396
405
|
{/if}
|
|
397
406
|
{/key}
|
|
407
|
+
</div>
|
|
398
408
|
{:else}
|
|
399
409
|
{#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">
|
|
410
|
+
<div class="mb-3 flex flex-none items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
|
|
401
411
|
<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
412
|
<input
|
|
403
413
|
type="search"
|
|
@@ -421,8 +431,10 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
|
|
|
421
431
|
<button type="button" class="text-[0.8125rem] font-medium text-primary underline [text-underline-offset:2px]" onclick={() => (query = '')}>Clear search</button>
|
|
422
432
|
</div>
|
|
423
433
|
{:else}
|
|
424
|
-
<!-- One scroll region holds every group, so the arrow keys roam the whole catalog.
|
|
425
|
-
|
|
434
|
+
<!-- One scroll region holds every group, so the arrow keys roam the whole catalog. It
|
|
435
|
+
is the box's scroll container (flex-1, min-h-0): the header above holds while the
|
|
436
|
+
list scrolls within the 85vh cap. -->
|
|
437
|
+
<div data-cairn-pk-list class="-mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
|
|
426
438
|
{#each groups as group (group.heading)}
|
|
427
439
|
<div class="mt-3 first:mt-0">
|
|
428
440
|
{#if group.heading}
|
|
@@ -200,6 +200,38 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
200
200
|
? `Published ${data.publishedAll} ${data.publishedAll === 1 ? 'entry' : 'entries'}.`
|
|
201
201
|
: '',
|
|
202
202
|
);
|
|
203
|
+
|
|
204
|
+
// The one lifecycle error to announce (the visible alerts below keep their own styling). A blocked
|
|
205
|
+
// delete leads, then a form error, then a load error, since the refusal is the most recent and most
|
|
206
|
+
// actionable outcome of the last submit. The refusal announcement carries the blocker count, so a
|
|
207
|
+
// screen reader hears the magnitude (matching the visible banner) before navigating to the list.
|
|
208
|
+
const lifecycleError = $derived(
|
|
209
|
+
deleteRefused
|
|
210
|
+
? `This ${data.label.toLowerCase()} could not be deleted. ${deleteRefused.inboundLinks.length} ${deleteRefused.inboundLinks.length === 1 ? 'page links' : 'pages link'} to it.`
|
|
211
|
+
: (data.formError ?? data.error ?? ''),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// The polite live region's text re-announces only when it changes, so a repeated identical error
|
|
215
|
+
// (a second submit failing the same way) would go silent. An invisible nonce flips on every fresh
|
|
216
|
+
// error so the region text always mutates and the screen reader speaks again (the MediaPicker
|
|
217
|
+
// discipline). The nonce is a zero-width space, never voiced, so the heard sentence is unchanged.
|
|
218
|
+
let announceNonce = $state(0);
|
|
219
|
+
function nonce(): string {
|
|
220
|
+
return announceNonce % 2 === 0 ? '' : '';
|
|
221
|
+
}
|
|
222
|
+
// Each submit hands a fresh `form` (or `data` on a load) object, so the nonce bumps once per submit,
|
|
223
|
+
// keying the re-announce to the submit rather than to a string change the live region would swallow.
|
|
224
|
+
// The guard reads a plain non-reactive `lastSubmit`, so the bump fires only when the submit identity
|
|
225
|
+
// changes, never on the re-render the bump itself causes; that is what keeps the effect from looping.
|
|
226
|
+
let lastSubmit: unknown;
|
|
227
|
+
$effect(() => {
|
|
228
|
+
const submit = form ?? data;
|
|
229
|
+
if (submit !== lastSubmit) {
|
|
230
|
+
lastSubmit = submit;
|
|
231
|
+
if (lifecycleError) announceNonce++;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
const liveError = $derived(lifecycleError ? `${lifecycleError}${nonce()}` : '');
|
|
203
235
|
</script>
|
|
204
236
|
|
|
205
237
|
<!-- The non-color selected cue for the triage controls (WCAG 1.4.1): a small check glyph that
|
|
@@ -225,20 +257,25 @@ header button. Filtering, sorting, and paging run over the loaded entries in com
|
|
|
225
257
|
{#if}-gated role element inserted fresh is announced inconsistently, so the visible alert
|
|
226
258
|
below keeps its styling without a role and the message is announced once. -->
|
|
227
259
|
<div class="sr-only" aria-live="polite">{publishedAllMessage}</div>
|
|
260
|
+
<!-- One persistent polite region announces the lifecycle errors, re-announcing a repeat through the
|
|
261
|
+
nonce. The visible alerts below keep their styling and drop the live `role` (a fresh-inserted
|
|
262
|
+
role element announces inconsistently and clobbers a repeat), so the message is announced once. -->
|
|
263
|
+
<div class="sr-only" aria-live="polite">{liveError}</div>
|
|
228
264
|
{#if publishedAllMessage}
|
|
229
265
|
<div class="alert alert-success mb-4 text-sm">{publishedAllMessage}</div>
|
|
230
266
|
{/if}
|
|
231
267
|
{#if data.formError}
|
|
232
|
-
<div
|
|
268
|
+
<div class="alert alert-error mb-4 text-sm">{data.formError}</div>
|
|
233
269
|
{/if}
|
|
234
270
|
{#if data.error}
|
|
235
|
-
<div
|
|
271
|
+
<div class="alert alert-warning mb-4 text-sm">{data.error}</div>
|
|
236
272
|
{/if}
|
|
237
273
|
|
|
238
274
|
{#if deleteRefused}
|
|
239
275
|
<!-- A `?/delete` was refused: name the blockers up front, matching the editor's refusal banner,
|
|
240
|
-
so the author sees why without re-opening a dialog.
|
|
241
|
-
|
|
276
|
+
so the author sees why without re-opening a dialog. The polite region above announces it, so
|
|
277
|
+
the box itself carries no role or label (a bare div with an aria-label gets no accessible name). -->
|
|
278
|
+
<div class="alert alert-error mb-4 flex-col items-start text-sm">
|
|
242
279
|
<p class="font-medium">This {data.label.toLowerCase()} could not be deleted.</p>
|
|
243
280
|
<p>{deleteRefused.inboundLinks.length} {deleteRefused.inboundLinks.length === 1 ? 'page links' : 'pages link'} to it. Remove or repoint the {deleteRefused.inboundLinks.length === 1 ? 'link' : 'links'} listed below, then delete again.</p>
|
|
244
281
|
<ul class="mt-1 w-full">
|
|
@@ -35,7 +35,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
35
35
|
import LinkPicker from './LinkPicker.svelte';
|
|
36
36
|
import WebLinkDialog from './WebLinkDialog.svelte';
|
|
37
37
|
import MediaInsertPopover from './MediaInsertPopover.svelte';
|
|
38
|
-
import
|
|
38
|
+
import FieldInput from './FieldInput.svelte';
|
|
39
|
+
import type MediaHeroField from './MediaHeroField.svelte';
|
|
39
40
|
import MediaFigureControl from './MediaFigureControl.svelte';
|
|
40
41
|
import DeleteDialog from './DeleteDialog.svelte';
|
|
41
42
|
import RenameDialog from './RenameDialog.svelte';
|
|
@@ -62,10 +63,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
62
63
|
import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
|
|
63
64
|
import type { IconSet } from '../render/glyph.js';
|
|
64
65
|
import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
|
|
65
|
-
import type {
|
|
66
|
-
import type { LinkResolve } from '../content/links.js';
|
|
66
|
+
import type { SiteRender } from '../content/types.js';
|
|
67
67
|
import { manifestLinkResolver } from '../content/manifest.js';
|
|
68
|
-
import type { MediaResolve } from '../render/resolve-media.js';
|
|
69
68
|
import { manifestMediaResolver } from '../render/resolve-media.js';
|
|
70
69
|
import type { MediaEntry } from '../media/manifest.js';
|
|
71
70
|
import { mediaLibraryEntry } from '../media/library-entry.js';
|
|
@@ -77,10 +76,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
77
76
|
/** The site's component registry, for the insert palette. */
|
|
78
77
|
registry?: ComponentRegistry;
|
|
79
78
|
/** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
|
|
80
|
-
render?:
|
|
81
|
-
md: string,
|
|
82
|
-
opts?: { stagger?: boolean; resolve?: LinkResolve; resolveMedia?: MediaResolve },
|
|
83
|
-
) => string | Promise<string>;
|
|
79
|
+
render?: SiteRender;
|
|
84
80
|
/** The site's icon set, for the guided form's icon fields. */
|
|
85
81
|
icons?: IconSet;
|
|
86
82
|
/** The last content action's failure: the save guard's broken links, the delete guard's
|
|
@@ -970,7 +966,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
970
966
|
// author can act on or leave; the count drops and the notice clears as each alt is filled.
|
|
971
967
|
const needsAlt = $derived(findMediaImagesNeedingAlt(body));
|
|
972
968
|
|
|
973
|
-
// The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows.
|
|
969
|
+
// The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows. Only
|
|
970
|
+
// top-level image fields are enumerated. A nested image (an array(image) gallery item or an object
|
|
971
|
+
// image sub-field) is intentionally out of scope for the needs-alt notice this phase, the recorded
|
|
972
|
+
// carry-forward, so the flat top-level scan is deliberate, not an oversight.
|
|
974
973
|
const imageFields = $derived(
|
|
975
974
|
data.fields.filter((f) => f.type === 'image').map((f) => ({ name: f.name, label: f.label })),
|
|
976
975
|
);
|
|
@@ -1061,6 +1060,14 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1061
1060
|
return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
1062
1061
|
});
|
|
1063
1062
|
|
|
1063
|
+
// A save whose frontmatter references an absent or draft target carries ?refs=<concept/id list>,
|
|
1064
|
+
// the advisory reference warning the save threads through (mirroring ?drafts=). It never blocks the
|
|
1065
|
+
// save; the build's verifyReferences is the integrity authority, so this is informational only.
|
|
1066
|
+
const referenceWarning = $derived.by(() => {
|
|
1067
|
+
const refs = page.url.searchParams.get('refs');
|
|
1068
|
+
return refs ? refs.split(',').filter(Boolean).join(', ') : '';
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1064
1071
|
// The one transient feedback strip under the sticky header. The redirect flags are mutually
|
|
1065
1072
|
// exclusive in practice; the chain picks one so a surprise overlap still renders a single strip.
|
|
1066
1073
|
// A saved flash with a draft warning yields to the warning alert below, the prior behavior.
|
|
@@ -1078,9 +1085,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1078
1085
|
// notices (the flash, plus the draft notice the strip yields to); an assertive region carries
|
|
1079
1086
|
// the errors. The visible banners below keep their styling but drop their roles, so a message
|
|
1080
1087
|
// is announced once.
|
|
1081
|
-
const politeMessage = $derived(
|
|
1082
|
-
draftWarning
|
|
1083
|
-
|
|
1088
|
+
const politeMessage = $derived.by(() => {
|
|
1089
|
+
if (draftWarning) return `Saved. This page links to unpublished pages: ${draftWarning}.`;
|
|
1090
|
+
if (referenceWarning) return `Saved. This page references unpublished entries: ${referenceWarning}.`;
|
|
1091
|
+
return flash;
|
|
1092
|
+
});
|
|
1084
1093
|
const assertiveMessage = $derived.by(() => {
|
|
1085
1094
|
if (data.error) return data.error;
|
|
1086
1095
|
if (formError) return formError;
|
|
@@ -1208,6 +1217,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1208
1217
|
|
|
1209
1218
|
// Render the design-accurate preview as the body changes, debounced. The site's render is the
|
|
1210
1219
|
// floored engine pipeline, so its output is already sanitized; the preview mirrors the page.
|
|
1220
|
+
// The preview call threads the same entry context the public route passes (concept and
|
|
1221
|
+
// frontmatter), so a custom entry-aware renderer's preview matches its page. The body is the live
|
|
1222
|
+
// editor content; frontmatter is the loaded snapshot, which is faithful for the saved state.
|
|
1211
1223
|
// previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
|
|
1212
1224
|
// async render call resolves after a newer one has started, the stale result is discarded.
|
|
1213
1225
|
let previewRun = 0;
|
|
@@ -1219,7 +1231,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1219
1231
|
const run = ++previewRun;
|
|
1220
1232
|
const handle = setTimeout(async () => {
|
|
1221
1233
|
try {
|
|
1222
|
-
const html = await render(md,
|
|
1234
|
+
const html = await render({ body: md, concept: data.conceptId, frontmatter: data.frontmatter, resolve, resolveMedia: resolveMediaRef });
|
|
1223
1235
|
if (run === previewRun) {
|
|
1224
1236
|
previewHtml = html;
|
|
1225
1237
|
previewFailed = false;
|
|
@@ -1254,11 +1266,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1254
1266
|
const titleField = $derived(data.fields.find((f) => f.name === 'title'));
|
|
1255
1267
|
const draftField = $derived(data.fields.find((f) => f.type === 'boolean' && f.name === 'draft'));
|
|
1256
1268
|
const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
|
|
1257
|
-
|
|
1258
|
-
// The built-in hint a date field carries when its adapter sets no description. The control reads as
|
|
1259
|
-
// if it might schedule publishing, so this reassures the editor that the date is metadata and that
|
|
1260
|
-
// publishing is the separate, deliberate step. A field-level description overrides it.
|
|
1261
|
-
const DATE_PUBLISH_HINT = 'Sets the date for this post. Publishing is a separate step you choose.';
|
|
1262
1269
|
</script>
|
|
1263
1270
|
|
|
1264
1271
|
<!-- The desk controls live in the one header band: AdminLayout renders this snippet through the
|
|
@@ -1385,17 +1392,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1385
1392
|
</div>
|
|
1386
1393
|
{/snippet}
|
|
1387
1394
|
|
|
1388
|
-
<!-- The author-facing hint under a Details field. The id pairs with the input's aria-describedby
|
|
1389
|
-
(`<name>-hint`); its uniqueness rests on schema field names being unique within a concept, which
|
|
1390
|
-
is also the loop key. So assistive tech announces the sentence without bloating the accessible
|
|
1391
|
-
name. Each field branch decides whether and where to render it; this snippet holds the one shape.
|
|
1392
|
-
The `fld-hint` class is a styling hook with no rule today; the Tailwind utilities do the work. -->
|
|
1393
|
-
{#snippet fieldHint(name: string, text: string)}
|
|
1394
|
-
<p id={`${name}-hint`} class="fld-hint mt-1 text-sm text-[var(--color-muted)]">
|
|
1395
|
-
{text}
|
|
1396
|
-
</p>
|
|
1397
|
-
{/snippet}
|
|
1398
|
-
|
|
1399
1395
|
<!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
|
|
1400
1396
|
reset above); script-level state and the beforeNavigate registration sit outside the block,
|
|
1401
1397
|
so only the template rebuilds. -->
|
|
@@ -1503,6 +1499,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1503
1499
|
Saved. Note: this page links to unpublished {draftWarning.includes(',') ? 'pages' : 'a page'} ({draftWarning}), which will 404 until published.
|
|
1504
1500
|
</div>
|
|
1505
1501
|
{/if}
|
|
1502
|
+
{#if referenceWarning}
|
|
1503
|
+
<div class="alert alert-warning mb-4 text-sm">
|
|
1504
|
+
Saved. Note: this page references {referenceWarning.includes(',') ? 'entries' : 'an entry'} ({referenceWarning}) not yet published, which the build will flag until published.
|
|
1505
|
+
</div>
|
|
1506
|
+
{/if}
|
|
1506
1507
|
|
|
1507
1508
|
<form
|
|
1508
1509
|
method="POST"
|
|
@@ -1884,96 +1885,19 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1884
1885
|
the screen-reader grouping but hides visually, the way the mockup carries it once. -->
|
|
1885
1886
|
<legend class="sr-only">Details</legend>
|
|
1886
1887
|
{#each detailFields as field (field.name)}
|
|
1887
|
-
|
|
1888
|
-
{
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
built-in publish-clarity default. So aria-describedby always points at the paragraph. -->
|
|
1901
|
-
<input class="input input-sm" type="date" name={field.name} aria-label={field.label} aria-describedby={`${field.name}-hint`} value={str(data.frontmatter[field.name])} />
|
|
1902
|
-
{@render fieldHint(field.name, field.description || DATE_PUBLISH_HINT)}
|
|
1903
|
-
</label>
|
|
1904
|
-
{:else if field.type === 'boolean'}
|
|
1905
|
-
<div class="flex flex-col gap-1">
|
|
1906
|
-
<label class="label cursor-pointer justify-start gap-2">
|
|
1907
|
-
<input class="checkbox checkbox-sm" type="checkbox" name={field.name} aria-label={field.label} aria-describedby={field.description ? `${field.name}-hint` : undefined} checked={data.frontmatter[field.name] === true} />
|
|
1908
|
-
<span class="text-sm">{field.label}</span>
|
|
1909
|
-
</label>
|
|
1910
|
-
{#if field.description}
|
|
1911
|
-
{@render fieldHint(field.name, field.description)}
|
|
1912
|
-
{/if}
|
|
1913
|
-
</div>
|
|
1914
|
-
{:else if field.type === 'tags'}
|
|
1915
|
-
{@const f = field as TagsField}
|
|
1916
|
-
{@const selected = (data.frontmatter[f.name] ?? []) as string[]}
|
|
1917
|
-
<fieldset class="fieldset" aria-describedby={f.description ? `${f.name}-hint` : undefined}>
|
|
1918
|
-
<legend class="fieldset-legend">{f.label}</legend>
|
|
1919
|
-
{#if f.description}
|
|
1920
|
-
{@render fieldHint(f.name, f.description)}
|
|
1921
|
-
{/if}
|
|
1922
|
-
<div class="flex flex-wrap gap-2">
|
|
1923
|
-
{#each f.options as option (option)}
|
|
1924
|
-
<label class="label cursor-pointer justify-start gap-2">
|
|
1925
|
-
<input
|
|
1926
|
-
class="checkbox checkbox-sm"
|
|
1927
|
-
type="checkbox"
|
|
1928
|
-
name={f.name}
|
|
1929
|
-
value={option}
|
|
1930
|
-
checked={selected.includes(option)}
|
|
1931
|
-
/>
|
|
1932
|
-
<span class="text-sm">{option}</span>
|
|
1933
|
-
</label>
|
|
1934
|
-
{/each}
|
|
1935
|
-
</div>
|
|
1936
|
-
</fieldset>
|
|
1937
|
-
{:else if field.type === 'freetags'}
|
|
1938
|
-
{@const f = field as FreeTagsField}
|
|
1939
|
-
{@const tagValue = ((data.frontmatter[f.name] ?? []) as string[]).join(', ')}
|
|
1940
|
-
<label class="flex flex-col gap-1">
|
|
1941
|
-
<span class="text-sm font-medium">{f.label}</span>
|
|
1942
|
-
<input
|
|
1943
|
-
class="input input-sm"
|
|
1944
|
-
name={f.name}
|
|
1945
|
-
aria-label={f.label}
|
|
1946
|
-
aria-describedby={f.description ? `${f.name}-hint` : undefined}
|
|
1947
|
-
placeholder={f.placeholder}
|
|
1948
|
-
value={tagValue}
|
|
1949
|
-
/>
|
|
1950
|
-
{#if f.description}
|
|
1951
|
-
{@render fieldHint(f.name, f.description)}
|
|
1952
|
-
{/if}
|
|
1953
|
-
</label>
|
|
1954
|
-
{:else if field.type === 'image'}
|
|
1955
|
-
{@const heroValue = data.frontmatter[field.name] as ImageValue | undefined}
|
|
1956
|
-
<MediaHeroField
|
|
1957
|
-
bind:this={heroFieldRefs[field.name]}
|
|
1958
|
-
field={{ name: field.name, label: field.label }}
|
|
1959
|
-
value={heroValue}
|
|
1960
|
-
decorative={heroValue?.decorative ?? false}
|
|
1961
|
-
mediaLibrary={mediaLibrary}
|
|
1962
|
-
conceptId={data.conceptId}
|
|
1963
|
-
id={data.id}
|
|
1964
|
-
onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
|
|
1965
|
-
ondirty={markFieldsDirty}
|
|
1966
|
-
onneedsaltchange={(n) => (heroNeedsAlt = { ...heroNeedsAlt, [field.name]: n })}
|
|
1967
|
-
/>
|
|
1968
|
-
{:else}
|
|
1969
|
-
<label class="flex flex-col gap-1">
|
|
1970
|
-
<span class="text-sm font-medium">{field.label}</span>
|
|
1971
|
-
<input class="input input-sm" name={field.name} aria-label={field.label} aria-describedby={field.description ? `${field.name}-hint` : undefined} value={str(data.frontmatter[field.name])} required={field.required} />
|
|
1972
|
-
{#if field.description}
|
|
1973
|
-
{@render fieldHint(field.name, field.description)}
|
|
1974
|
-
{/if}
|
|
1975
|
-
</label>
|
|
1976
|
-
{/if}
|
|
1888
|
+
<FieldInput
|
|
1889
|
+
{field}
|
|
1890
|
+
frontmatter={data.frontmatter}
|
|
1891
|
+
targets={data.linkTargets}
|
|
1892
|
+
markFieldsDirty={markFieldsDirty}
|
|
1893
|
+
mediaLibrary={mediaLibrary}
|
|
1894
|
+
conceptId={data.conceptId}
|
|
1895
|
+
id={data.id}
|
|
1896
|
+
heroFieldRefs={heroFieldRefs}
|
|
1897
|
+
onuploaded={(record) => (uploadedRecords = [...uploadedRecords, record])}
|
|
1898
|
+
onheroneedsalt={(name, n) => (heroNeedsAlt = { ...heroNeedsAlt, [name]: n })}
|
|
1899
|
+
{icons}
|
|
1900
|
+
/>
|
|
1977
1901
|
{/each}
|
|
1978
1902
|
</fieldset>
|
|
1979
1903
|
{/if}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The search + concept-grouped target list shared by the editor's "Link to page" control and the
|
|
4
|
+
reference field picker. It lists link targets from the committed manifest, grouped by concept with
|
|
5
|
+
Pages first then Posts then any other concept, each post showing its date and each draft marked, and
|
|
6
|
+
fires choose() with the picked target. It knows nothing about cairn: tokens or the editor cursor; the
|
|
7
|
+
host decides what a chosen target means. An optional conceptFilter narrows the list to one concept,
|
|
8
|
+
and selectedIds marks rows the host already holds. Built on a native <dialog>, following the component
|
|
9
|
+
dialog's a11y conventions.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import type { LinkTarget } from '../content/manifest.js';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
/** The site's link targets, from the committed manifest (editLoad ships them). */
|
|
16
|
+
targets: LinkTarget[];
|
|
17
|
+
/** Called with the target the user picked; the host decides what to do with it. */
|
|
18
|
+
choose: (target: LinkTarget) => void;
|
|
19
|
+
/** Narrow the list to a single concept (the reference field's concept). */
|
|
20
|
+
conceptFilter?: string;
|
|
21
|
+
/** Ids the host already holds; matching rows render as already-selected and do not re-fire choose. */
|
|
22
|
+
selectedIds?: string[];
|
|
23
|
+
/** Render the built-in trigger button. False mounts only the dialog, for a host that supplies its
|
|
24
|
+
* own trigger and opens the dialog through the exported open(). */
|
|
25
|
+
trigger?: boolean;
|
|
26
|
+
/** The dialog title. Defaults to the editor link control's wording; a reference field passes a
|
|
27
|
+
* concept-appropriate heading. */
|
|
28
|
+
heading?: string;
|
|
29
|
+
/** The search input's accessible name. Defaults to the link control's wording. */
|
|
30
|
+
searchLabel?: string;
|
|
31
|
+
/** The empty-state text shown when no target matches. Defaults to the link control's wording. */
|
|
32
|
+
emptyText?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
targets,
|
|
37
|
+
choose,
|
|
38
|
+
conceptFilter,
|
|
39
|
+
selectedIds = [],
|
|
40
|
+
trigger = true,
|
|
41
|
+
heading: dialogHeading = 'Link to a page',
|
|
42
|
+
searchLabel = 'Search pages and posts',
|
|
43
|
+
emptyText = 'No pages or posts to link to.',
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
46
|
+
let dialog = $state<HTMLDialogElement | null>(null);
|
|
47
|
+
let searchInput = $state<HTMLInputElement | null>(null);
|
|
48
|
+
let query = $state('');
|
|
49
|
+
|
|
50
|
+
// Group filtered targets by concept, Pages first then Posts then any other concept, so the list
|
|
51
|
+
// reads in a stable order. The filter is a case-insensitive title substring.
|
|
52
|
+
const ORDER: Record<string, number> = { pages: 0, posts: 1 };
|
|
53
|
+
function rank(concept: string): number {
|
|
54
|
+
return ORDER[concept] ?? 2;
|
|
55
|
+
}
|
|
56
|
+
function heading(concept: string): string {
|
|
57
|
+
if (concept === 'pages') return 'Pages';
|
|
58
|
+
if (concept === 'posts') return 'Posts';
|
|
59
|
+
return concept.charAt(0).toUpperCase() + concept.slice(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const groups = $derived.by(() => {
|
|
63
|
+
const q = query.trim().toLowerCase();
|
|
64
|
+
const scoped = conceptFilter ? targets.filter((t) => t.concept === conceptFilter) : targets;
|
|
65
|
+
const matched = q ? scoped.filter((t) => t.title.toLowerCase().includes(q)) : scoped;
|
|
66
|
+
const byConcept = new Map<string, LinkTarget[]>();
|
|
67
|
+
for (const t of matched) {
|
|
68
|
+
const list = byConcept.get(t.concept) ?? [];
|
|
69
|
+
list.push(t);
|
|
70
|
+
byConcept.set(t.concept, list);
|
|
71
|
+
}
|
|
72
|
+
return [...byConcept.entries()]
|
|
73
|
+
.map(([concept, items]) => ({ concept, heading: heading(concept), items }))
|
|
74
|
+
.sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function isSelected(target: LinkTarget): boolean {
|
|
78
|
+
return selectedIds.includes(target.id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Open the picker programmatically, for a host that drives it without the trigger. */
|
|
82
|
+
export function open() {
|
|
83
|
+
query = '';
|
|
84
|
+
dialog?.showModal();
|
|
85
|
+
// showModal() lands focus on the first focusable element (the header Close button), so move it
|
|
86
|
+
// to the search box the picker exists for (WCAG 2.4.3). A microtask defers past the dialog's own
|
|
87
|
+
// focus handling, matching RenameDialog.
|
|
88
|
+
queueMicrotask(() => searchInput?.focus());
|
|
89
|
+
}
|
|
90
|
+
function close() {
|
|
91
|
+
dialog?.close();
|
|
92
|
+
}
|
|
93
|
+
function pick(target: LinkTarget) {
|
|
94
|
+
if (isSelected(target)) return;
|
|
95
|
+
choose(target);
|
|
96
|
+
close();
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
{#if trigger}
|
|
101
|
+
<button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Link to page" onclick={open}>
|
|
102
|
+
Link to page
|
|
103
|
+
</button>
|
|
104
|
+
{/if}
|
|
105
|
+
|
|
106
|
+
<dialog class="modal" aria-labelledby="cairn-entry-picker-title" bind:this={dialog}>
|
|
107
|
+
<div class="modal-box">
|
|
108
|
+
<div class="mb-3 flex items-center justify-between">
|
|
109
|
+
<h2 id="cairn-entry-picker-title" class="text-base font-semibold">{dialogHeading}</h2>
|
|
110
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<input
|
|
114
|
+
type="search"
|
|
115
|
+
class="input input-bordered mb-3 w-full"
|
|
116
|
+
placeholder="Search by title"
|
|
117
|
+
aria-label={searchLabel}
|
|
118
|
+
bind:this={searchInput}
|
|
119
|
+
bind:value={query}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
{#if groups.length === 0}
|
|
123
|
+
<p class="text-sm text-[var(--color-muted)]">{emptyText}</p>
|
|
124
|
+
{:else}
|
|
125
|
+
{#each groups as group (group.concept)}
|
|
126
|
+
<h3 class="mt-2 mb-1 text-xs font-semibold tracking-wide text-[var(--color-muted)] uppercase">{group.heading}</h3>
|
|
127
|
+
<ul class="menu w-full">
|
|
128
|
+
{#each group.items as target (`${target.concept}/${target.id}`)}
|
|
129
|
+
<li>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
aria-disabled={isSelected(target)}
|
|
133
|
+
aria-label={isSelected(target) ? `${target.title} (already selected)` : target.title}
|
|
134
|
+
onclick={() => pick(target)}
|
|
135
|
+
>
|
|
136
|
+
<span class="flex flex-col items-start">
|
|
137
|
+
<span class="font-medium">{target.title}</span>
|
|
138
|
+
<span class="text-xs text-[var(--color-muted)]">
|
|
139
|
+
{#if isSelected(target)}<span class="badge badge-ghost badge-sm mr-1">Selected</span>{/if}
|
|
140
|
+
{#if target.draft}<span class="badge badge-ghost badge-sm mr-1">Draft</span>{/if}
|
|
141
|
+
{#if target.date}{target.date}{/if}
|
|
142
|
+
</span>
|
|
143
|
+
</span>
|
|
144
|
+
</button>
|
|
145
|
+
</li>
|
|
146
|
+
{/each}
|
|
147
|
+
</ul>
|
|
148
|
+
{/each}
|
|
149
|
+
{/if}
|
|
150
|
+
</div>
|
|
151
|
+
<form method="dialog" class="modal-backdrop">
|
|
152
|
+
<button tabindex="-1" aria-label="Close">close</button>
|
|
153
|
+
</form>
|
|
154
|
+
</dialog>
|