@glw907/cairn-cms 0.56.2 → 0.57.1
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 +134 -0
- 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 +949 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/EditPage.svelte +348 -7
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +283 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +37 -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 +578 -0
- package/dist/components/MediaHeroField.svelte.d.ts +75 -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 +901 -9
- 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 +12 -0
- package/dist/components/markdown-directives.js +42 -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 +22 -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 +64 -11
- package/dist/content/validate.js +31 -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/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.js +3 -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 +77 -2
- package/dist/sveltekit/content-routes.js +470 -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 +7 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +949 -0
- package/src/lib/components/EditPage.svelte +348 -7
- package/src/lib/components/MarkdownEditor.svelte +283 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +578 -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 +46 -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 +20 -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 +80 -13
- package/src/lib/content/validate.ts +29 -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/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +5 -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 +589 -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,257 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The combobox picker over the site's committed media library, read-only. The host (Task 6's insert
|
|
4
|
+
popover) passes the projected library in as a prop and receives the chosen asset through onselect,
|
|
5
|
+
which hands back the asset entry, its media: reference token, and the manifest alt to prefill the
|
|
6
|
+
placement.
|
|
7
|
+
|
|
8
|
+
This is a real WAI-ARIA combobox over a listbox: focus stays in the search input at all times, and
|
|
9
|
+
aria-activedescendant moves the active option through arrow keys. The input never loses DOM focus
|
|
10
|
+
during navigation, so a screen reader follows the active row through the input's owned listbox
|
|
11
|
+
rather than a roving tabindex. Two separate aria-live regions report the results count and narrate
|
|
12
|
+
the active row, so one announcement never clobbers the other.
|
|
13
|
+
|
|
14
|
+
Each row carries a decorative thumbnail (the bare delivery path under transformations: false), the
|
|
15
|
+
display name, and a needs-alt flag (a glyph plus a label, never hue alone) when the asset's alt is
|
|
16
|
+
empty. Search filters across the display name and the alt, case-insensitive.
|
|
17
|
+
|
|
18
|
+
The media-type facet (Images, Documents) is a designed-in seam: it renders only once the library
|
|
19
|
+
holds more than one distinct top-level content type, so the structure is present without dead UI
|
|
20
|
+
while a site stores images only.
|
|
21
|
+
-->
|
|
22
|
+
<script module lang="ts">
|
|
23
|
+
// The picker's library entry is the shared node-safe projection (../media/library-entry), not a
|
|
24
|
+
// type from editor-media.ts: importing that module would pull CodeMirror into a bundle, which the
|
|
25
|
+
// editor-boundary test bars. Re-exported so the insert popover keeps importing it from here.
|
|
26
|
+
import type { MediaLibraryEntry } from '../media/library-entry.js';
|
|
27
|
+
export type { MediaLibraryEntry };
|
|
28
|
+
|
|
29
|
+
/** The picked asset the picker emits to its host: the library entry, its media: reference token,
|
|
30
|
+
* and the manifest alt to prefill the placement. */
|
|
31
|
+
export interface MediaSelection {
|
|
32
|
+
/** The chosen library entry. */
|
|
33
|
+
entry: MediaLibraryEntry;
|
|
34
|
+
/** The media: reference token (`media:<slug>.<hash>`) to commit at the caret. */
|
|
35
|
+
ref: string;
|
|
36
|
+
/** The asset's manifest alt, prefilling the placement; empty means the placement needs alt. */
|
|
37
|
+
alt: string;
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<script lang="ts">
|
|
42
|
+
import { mediaToken } from '../media/reference.js';
|
|
43
|
+
// The bare delivery path under transformations: false (the same path the Task 3 source chip uses).
|
|
44
|
+
// SEAM: when transformations are on, the row thumbnail should request the `thumb` preset URL
|
|
45
|
+
// instead of the bare path; that is a later transformations-on refinement.
|
|
46
|
+
import { publicPath } from '../media/naming.js';
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
/** The committed media library projection, keyed by the 16-hex content hash. */
|
|
50
|
+
library: Record<string, MediaLibraryEntry>;
|
|
51
|
+
/** Emit the chosen asset to the host: the entry, its media: reference, and the manifest alt. */
|
|
52
|
+
onselect: (selection: MediaSelection) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let { library, onselect }: Props = $props();
|
|
56
|
+
|
|
57
|
+
// A stable id base so the listbox and each option carry unique ids the combobox can point at.
|
|
58
|
+
// $props.id() is Svelte's deterministic, hydration-stable id source (the same value SSR and the
|
|
59
|
+
// client), unlike a Math.random() base that would mismatch across hydration. It must initialize a
|
|
60
|
+
// top-level variable directly, so the prefix is composed in a second declaration.
|
|
61
|
+
const uid = $props.id();
|
|
62
|
+
const idBase = `cairn-mp-${uid}`;
|
|
63
|
+
const listboxId = `${idBase}-listbox`;
|
|
64
|
+
|
|
65
|
+
let query = $state('');
|
|
66
|
+
// The media-type facet selection: 'all' or a top-level content type ('image', 'application').
|
|
67
|
+
let typeFilter = $state<string>('all');
|
|
68
|
+
// The index of the active option within the filtered list, or -1 for none active yet.
|
|
69
|
+
let activeIndex = $state(-1);
|
|
70
|
+
|
|
71
|
+
const entries = $derived(Object.values(library));
|
|
72
|
+
|
|
73
|
+
// The distinct top-level content types in the library, in first-seen order. The facet is a seam:
|
|
74
|
+
// it renders only when more than one distinct type exists, so a site storing images only sees no
|
|
75
|
+
// dead UI. ('image/webp' and 'image/png' both fold to 'image'.)
|
|
76
|
+
const distinctTypes = $derived.by(() => {
|
|
77
|
+
const seen: string[] = [];
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const top = e.contentType.split('/')[0] ?? '';
|
|
80
|
+
if (top && !seen.includes(top)) seen.push(top);
|
|
81
|
+
}
|
|
82
|
+
return seen;
|
|
83
|
+
});
|
|
84
|
+
const showFacet = $derived(distinctTypes.length > 1);
|
|
85
|
+
|
|
86
|
+
/** The label for a top-level content type, for the facet chips. */
|
|
87
|
+
function typeLabel(top: string): string {
|
|
88
|
+
if (top === 'image') return 'Images';
|
|
89
|
+
if (top === 'application') return 'Documents';
|
|
90
|
+
return top.charAt(0).toUpperCase() + top.slice(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The filtered, displayed options: the type facet first, then a case-insensitive substring match
|
|
94
|
+
// across the display name and the alt. Order follows the library's insertion order.
|
|
95
|
+
const filtered = $derived.by(() => {
|
|
96
|
+
const q = query.trim().toLowerCase();
|
|
97
|
+
return entries.filter((e) => {
|
|
98
|
+
if (typeFilter !== 'all' && (e.contentType.split('/')[0] ?? '') !== typeFilter) return false;
|
|
99
|
+
if (!q) return true;
|
|
100
|
+
return (
|
|
101
|
+
e.displayName.toLowerCase().includes(q) || e.alt.toLowerCase().includes(q)
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Keep the active index in range as the filtered list narrows; a filter that drops the active row
|
|
107
|
+
// clears the active descendant rather than pointing at a gone option.
|
|
108
|
+
$effect(() => {
|
|
109
|
+
if (activeIndex >= filtered.length) activeIndex = filtered.length === 0 ? -1 : filtered.length - 1;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** The per-row option id, used by aria-activedescendant and the live narration. */
|
|
113
|
+
function optionId(i: number): string {
|
|
114
|
+
return `${idBase}-opt-${i}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const activeEntry = $derived(activeIndex >= 0 ? (filtered[activeIndex] ?? null) : null);
|
|
118
|
+
const activeDescendant = $derived(activeEntry ? optionId(activeIndex) : undefined);
|
|
119
|
+
|
|
120
|
+
// The active-row narration text, kept in its own live region so it never clobbers the count.
|
|
121
|
+
const activeNarration = $derived(
|
|
122
|
+
activeEntry
|
|
123
|
+
? `${activeEntry.displayName}${activeEntry.alt.trim() === '' ? ', needs alt text' : ''}, ${activeIndex + 1} of ${filtered.length}`
|
|
124
|
+
: '',
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/** Build a selection from a library entry: its media: token and its manifest alt. */
|
|
128
|
+
function select(entry: MediaLibraryEntry) {
|
|
129
|
+
onselect({ entry, ref: mediaToken({ slug: entry.slug, hash: entry.hash }), alt: entry.alt });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onKeydown(e: KeyboardEvent) {
|
|
133
|
+
if (e.key === 'ArrowDown') {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
if (filtered.length === 0) return;
|
|
136
|
+
// Clamp at the last option (a deliberate non-wrap, fine per the task).
|
|
137
|
+
activeIndex = Math.min(activeIndex + 1, filtered.length - 1);
|
|
138
|
+
} else if (e.key === 'ArrowUp') {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
if (filtered.length === 0) return;
|
|
141
|
+
activeIndex = Math.max(activeIndex - 1, 0);
|
|
142
|
+
} else if (e.key === 'Home') {
|
|
143
|
+
if (filtered.length === 0) return;
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
activeIndex = 0;
|
|
146
|
+
} else if (e.key === 'End') {
|
|
147
|
+
if (filtered.length === 0) return;
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
activeIndex = filtered.length - 1;
|
|
150
|
+
} else if (e.key === 'Enter') {
|
|
151
|
+
if (activeEntry) {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
select(activeEntry);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Escape is handled by the host popover (Task 6); let it bubble.
|
|
157
|
+
}
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<div class="flex flex-col gap-3">
|
|
161
|
+
<!-- The media-type facet: a designed-in seam, rendered only past one distinct stored type. -->
|
|
162
|
+
{#if showFacet}
|
|
163
|
+
<div data-testid="cairn-mp-facet" class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by type">
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
class="btn btn-xs {typeFilter === 'all' ? 'btn-primary' : 'btn-ghost'}"
|
|
167
|
+
aria-pressed={typeFilter === 'all'}
|
|
168
|
+
onclick={() => (typeFilter = 'all')}>All</button>
|
|
169
|
+
{#each distinctTypes as top (top)}
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
class="btn btn-xs {typeFilter === top ? 'btn-primary' : 'btn-ghost'}"
|
|
173
|
+
aria-pressed={typeFilter === top}
|
|
174
|
+
onclick={() => (typeFilter = top)}>{typeLabel(top)}</button>
|
|
175
|
+
{/each}
|
|
176
|
+
</div>
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
<!-- The combobox: focus stays in this input; aria-activedescendant tracks the active option. -->
|
|
180
|
+
<div class="flex items-center gap-2 rounded-field border border-[var(--cairn-card-border)] bg-base-100 px-3 py-2">
|
|
181
|
+
<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>
|
|
182
|
+
<input
|
|
183
|
+
bind:value={query}
|
|
184
|
+
onkeydown={onKeydown}
|
|
185
|
+
type="text"
|
|
186
|
+
role="combobox"
|
|
187
|
+
class="w-full border-0 bg-transparent p-0 text-sm outline-hidden placeholder:text-[var(--color-muted)]"
|
|
188
|
+
placeholder="Search the media library"
|
|
189
|
+
aria-label="Search the media library"
|
|
190
|
+
aria-expanded={filtered.length > 0}
|
|
191
|
+
aria-controls={listboxId}
|
|
192
|
+
aria-activedescendant={activeDescendant}
|
|
193
|
+
autocomplete="off"
|
|
194
|
+
spellcheck="false"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<!-- The count region: polite, separate from the active-row narration so neither clobbers the other. -->
|
|
199
|
+
<p class="sr-only" role="status" aria-live="polite">
|
|
200
|
+
{filtered.length} {filtered.length === 1 ? 'image' : 'images'}
|
|
201
|
+
</p>
|
|
202
|
+
<!-- The active-row narration, its own polite region. -->
|
|
203
|
+
<p class="sr-only" aria-live="polite">{activeNarration}</p>
|
|
204
|
+
|
|
205
|
+
<!-- The listbox always renders, even with no matches, so the combobox's aria-controls always
|
|
206
|
+
resolves to a real element (WCAG 1.3.1, 4.1.2): an aria-controls pointing at a node that does
|
|
207
|
+
not exist is a broken relationship. The no-match copy lives inside the listbox; the rows render
|
|
208
|
+
only when there are matches. The whole listbox is the input's owned popup, so a click on a row
|
|
209
|
+
selects without moving focus out of the input. -->
|
|
210
|
+
<ul id={listboxId} role="listbox" aria-label="Media library" class="flex max-h-72 flex-col gap-0.5 overflow-auto p-0">
|
|
211
|
+
{#if filtered.length === 0}
|
|
212
|
+
<li class="flex flex-col items-center gap-2 px-6 py-10 text-center">
|
|
213
|
+
<p class="text-sm text-[var(--color-muted)]">
|
|
214
|
+
{#if entries.length === 0}
|
|
215
|
+
No images in the library yet.
|
|
216
|
+
{:else}
|
|
217
|
+
Nothing matches <span class="font-medium text-base-content">"{query.trim()}"</span>.
|
|
218
|
+
{/if}
|
|
219
|
+
</p>
|
|
220
|
+
</li>
|
|
221
|
+
{:else}
|
|
222
|
+
{#each filtered as entry, i (entry.hash)}
|
|
223
|
+
<!-- A listbox option carries no keyboard handler of its own: in the ARIA combobox pattern the
|
|
224
|
+
keyboard model lives on the input (arrows move aria-activedescendant, Enter selects), and
|
|
225
|
+
focus never leaves the input. The click handler is the pointer path to the same select. -->
|
|
226
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
227
|
+
<li
|
|
228
|
+
id={optionId(i)}
|
|
229
|
+
role="option"
|
|
230
|
+
aria-selected={i === activeIndex}
|
|
231
|
+
class="flex cursor-pointer items-start gap-3 rounded-field px-2 py-2 {i === activeIndex
|
|
232
|
+
? 'bg-base-content/[0.08]'
|
|
233
|
+
: 'hover:bg-base-content/[0.04]'}"
|
|
234
|
+
onclick={() => select(entry)}
|
|
235
|
+
>
|
|
236
|
+
<img
|
|
237
|
+
src={publicPath(entry.slug, entry.hash, entry.ext, 'slug')}
|
|
238
|
+
alt=""
|
|
239
|
+
aria-hidden="true"
|
|
240
|
+
class="h-10 w-10 flex-none rounded-box border border-[var(--cairn-card-border)] object-cover"
|
|
241
|
+
/>
|
|
242
|
+
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
243
|
+
<span class="truncate text-sm font-medium">{entry.displayName || entry.slug || entry.hash}</span>
|
|
244
|
+
{#if entry.alt.trim() === ''}
|
|
245
|
+
<!-- The needs-alt flag: a glyph plus a label, never hue alone (the spec a11y rule),
|
|
246
|
+
matching the Task 3 source-chip treatment. -->
|
|
247
|
+
<span class="inline-flex items-center gap-1 text-xs font-medium text-[var(--cairn-warning-ink)]">
|
|
248
|
+
<span aria-hidden="true">⚠</span>
|
|
249
|
+
<span>Needs alt</span>
|
|
250
|
+
</span>
|
|
251
|
+
{/if}
|
|
252
|
+
</span>
|
|
253
|
+
</li>
|
|
254
|
+
{/each}
|
|
255
|
+
{/if}
|
|
256
|
+
</ul>
|
|
257
|
+
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { MediaLibraryEntry } from '../media/library-entry.js';
|
|
2
|
+
export type { MediaLibraryEntry };
|
|
3
|
+
/** The picked asset the picker emits to its host: the library entry, its media: reference token,
|
|
4
|
+
* and the manifest alt to prefill the placement. */
|
|
5
|
+
export interface MediaSelection {
|
|
6
|
+
/** The chosen library entry. */
|
|
7
|
+
entry: MediaLibraryEntry;
|
|
8
|
+
/** The media: reference token (`media:<slug>.<hash>`) to commit at the caret. */
|
|
9
|
+
ref: string;
|
|
10
|
+
/** The asset's manifest alt, prefilling the placement; empty means the placement needs alt. */
|
|
11
|
+
alt: string;
|
|
12
|
+
}
|
|
13
|
+
interface Props {
|
|
14
|
+
/** The committed media library projection, keyed by the 16-hex content hash. */
|
|
15
|
+
library: Record<string, MediaLibraryEntry>;
|
|
16
|
+
/** Emit the chosen asset to the host: the entry, its media: reference, and the manifest alt. */
|
|
17
|
+
onselect: (selection: MediaSelection) => void;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The combobox picker over the site's committed media library, read-only. The host (Task 6's insert
|
|
21
|
+
* popover) passes the projected library in as a prop and receives the chosen asset through onselect,
|
|
22
|
+
* which hands back the asset entry, its media: reference token, and the manifest alt to prefill the
|
|
23
|
+
* placement.
|
|
24
|
+
*
|
|
25
|
+
* This is a real WAI-ARIA combobox over a listbox: focus stays in the search input at all times, and
|
|
26
|
+
* aria-activedescendant moves the active option through arrow keys. The input never loses DOM focus
|
|
27
|
+
* during navigation, so a screen reader follows the active row through the input's owned listbox
|
|
28
|
+
* rather than a roving tabindex. Two separate aria-live regions report the results count and narrate
|
|
29
|
+
* the active row, so one announcement never clobbers the other.
|
|
30
|
+
*
|
|
31
|
+
* Each row carries a decorative thumbnail (the bare delivery path under transformations: false), the
|
|
32
|
+
* display name, and a needs-alt flag (a glyph plus a label, never hue alone) when the asset's alt is
|
|
33
|
+
* empty. Search filters across the display name and the alt, case-insensitive.
|
|
34
|
+
*
|
|
35
|
+
* The media-type facet (Images, Documents) is a designed-in seam: it renders only once the library
|
|
36
|
+
* holds more than one distinct top-level content type, so the structure is present without dead UI
|
|
37
|
+
* while a site stores images only.
|
|
38
|
+
*/
|
|
39
|
+
declare const MediaPicker: import("svelte").Component<Props, {}, "">;
|
|
40
|
+
type MediaPicker = ReturnType<typeof MediaPicker>;
|
|
41
|
+
export default MediaPicker;
|
|
@@ -11,3 +11,15 @@ export { default as SunIcon } from '@lucide/svelte/icons/sun';
|
|
|
11
11
|
export { default as MoonIcon } from '@lucide/svelte/icons/moon';
|
|
12
12
|
export { default as ChevronLeftIcon } from '@lucide/svelte/icons/chevron-left';
|
|
13
13
|
export { default as ChevronRightIcon } from '@lucide/svelte/icons/chevron-right';
|
|
14
|
+
export { default as ChevronDownIcon } from '@lucide/svelte/icons/chevron-down';
|
|
15
|
+
export { default as UploadIcon } from '@lucide/svelte/icons/upload';
|
|
16
|
+
export { default as LayoutGridIcon } from '@lucide/svelte/icons/layout-grid';
|
|
17
|
+
export { default as ListIcon } from '@lucide/svelte/icons/list';
|
|
18
|
+
export { default as ImageOffIcon } from '@lucide/svelte/icons/image-off';
|
|
19
|
+
export { default as CheckIcon } from '@lucide/svelte/icons/check';
|
|
20
|
+
export { default as TriangleAlertIcon } from '@lucide/svelte/icons/triangle-alert';
|
|
21
|
+
export { default as XIcon } from '@lucide/svelte/icons/x';
|
|
22
|
+
export { default as CopyIcon } from '@lucide/svelte/icons/copy';
|
|
23
|
+
export { default as FileTextIcon } from '@lucide/svelte/icons/file-text';
|
|
24
|
+
export { default as ClockIcon } from '@lucide/svelte/icons/clock';
|
|
25
|
+
export { default as Link2OffIcon } from '@lucide/svelte/icons/link-2-off';
|
|
@@ -13,3 +13,15 @@ export { default as SunIcon } from '@lucide/svelte/icons/sun';
|
|
|
13
13
|
export { default as MoonIcon } from '@lucide/svelte/icons/moon';
|
|
14
14
|
export { default as ChevronLeftIcon } from '@lucide/svelte/icons/chevron-left';
|
|
15
15
|
export { default as ChevronRightIcon } from '@lucide/svelte/icons/chevron-right';
|
|
16
|
+
export { default as ChevronDownIcon } from '@lucide/svelte/icons/chevron-down';
|
|
17
|
+
export { default as UploadIcon } from '@lucide/svelte/icons/upload';
|
|
18
|
+
export { default as LayoutGridIcon } from '@lucide/svelte/icons/layout-grid';
|
|
19
|
+
export { default as ListIcon } from '@lucide/svelte/icons/list';
|
|
20
|
+
export { default as ImageOffIcon } from '@lucide/svelte/icons/image-off';
|
|
21
|
+
export { default as CheckIcon } from '@lucide/svelte/icons/check';
|
|
22
|
+
export { default as TriangleAlertIcon } from '@lucide/svelte/icons/triangle-alert';
|
|
23
|
+
export { default as XIcon } from '@lucide/svelte/icons/x';
|
|
24
|
+
export { default as CopyIcon } from '@lucide/svelte/icons/copy';
|
|
25
|
+
export { default as FileTextIcon } from '@lucide/svelte/icons/file-text';
|
|
26
|
+
export { default as ClockIcon } from '@lucide/svelte/icons/clock';
|
|
27
|
+
export { default as Link2OffIcon } from '@lucide/svelte/icons/link-2-off';
|