@glw907/cairn-cms 0.59.0 → 0.60.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.
Files changed (106) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/components/AdminLayout.svelte +130 -229
  3. package/dist/components/CairnAdmin.svelte +12 -41
  4. package/dist/components/CairnLogo.svelte +1 -6
  5. package/dist/components/CairnMediaLibrary.svelte +821 -1210
  6. package/dist/components/CairnTidySettings.svelte +486 -0
  7. package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
  8. package/dist/components/ComponentForm.svelte +110 -185
  9. package/dist/components/ComponentInsertDialog.svelte +163 -283
  10. package/dist/components/ConceptList.svelte +111 -191
  11. package/dist/components/ConfirmPage.svelte +5 -12
  12. package/dist/components/CsrfField.svelte +5 -11
  13. package/dist/components/DeleteDialog.svelte +15 -42
  14. package/dist/components/EditPage.svelte +786 -918
  15. package/dist/components/EditorToolbar.svelte +108 -170
  16. package/dist/components/IconPicker.svelte +23 -53
  17. package/dist/components/LinkPicker.svelte +34 -58
  18. package/dist/components/LoginPage.svelte +14 -27
  19. package/dist/components/ManageEditors.svelte +3 -15
  20. package/dist/components/MarkdownEditor.svelte +688 -789
  21. package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
  22. package/dist/components/MarkdownHelpDialog.svelte +8 -12
  23. package/dist/components/MediaCaptureCard.svelte +18 -57
  24. package/dist/components/MediaFigureControl.svelte +32 -71
  25. package/dist/components/MediaHeroField.svelte +210 -329
  26. package/dist/components/MediaInsertPopover.svelte +156 -283
  27. package/dist/components/MediaPicker.svelte +67 -131
  28. package/dist/components/NavTree.svelte +46 -78
  29. package/dist/components/RenameDialog.svelte +16 -43
  30. package/dist/components/ShortcutsDialog.svelte +9 -13
  31. package/dist/components/ShortcutsGrid.svelte +1 -2
  32. package/dist/components/TidyReview.svelte +355 -0
  33. package/dist/components/TidyReview.svelte.d.ts +47 -0
  34. package/dist/components/WebLinkDialog.svelte +19 -40
  35. package/dist/components/cairn-admin.css +768 -0
  36. package/dist/components/editor-tidy.d.ts +31 -0
  37. package/dist/components/editor-tidy.js +199 -0
  38. package/dist/components/index.d.ts +1 -0
  39. package/dist/components/index.js +1 -0
  40. package/dist/components/markdown-directives.d.ts +16 -0
  41. package/dist/components/markdown-directives.js +34 -0
  42. package/dist/components/objective-errors.d.ts +30 -0
  43. package/dist/components/objective-errors.js +113 -0
  44. package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  45. package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  46. package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  47. package/dist/components/spellcheck-worker.d.ts +80 -0
  48. package/dist/components/spellcheck-worker.js +161 -0
  49. package/dist/components/spellcheck.d.ts +148 -0
  50. package/dist/components/spellcheck.js +553 -0
  51. package/dist/components/tidy-categorize.d.ts +67 -0
  52. package/dist/components/tidy-categorize.js +392 -0
  53. package/dist/components/tidy-diff.d.ts +60 -0
  54. package/dist/components/tidy-diff.js +147 -0
  55. package/dist/components/tidy-validate.d.ts +37 -0
  56. package/dist/components/tidy-validate.js +174 -0
  57. package/dist/content/compose.d.ts +1 -1
  58. package/dist/content/compose.js +11 -0
  59. package/dist/content/site-dictionary.d.ts +31 -0
  60. package/dist/content/site-dictionary.js +82 -0
  61. package/dist/content/types.d.ts +25 -0
  62. package/dist/delivery/CairnHead.svelte +8 -11
  63. package/dist/doctor/checks-local.d.ts +1 -0
  64. package/dist/doctor/checks-local.js +55 -6
  65. package/dist/doctor/index.js +2 -1
  66. package/dist/log/events.d.ts +1 -1
  67. package/dist/nav/site-config.d.ts +98 -0
  68. package/dist/nav/site-config.js +132 -0
  69. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  70. package/dist/sveltekit/admin-dispatch.js +6 -2
  71. package/dist/sveltekit/cairn-admin.d.ts +13 -1
  72. package/dist/sveltekit/cairn-admin.js +22 -3
  73. package/dist/sveltekit/content-routes.d.ts +135 -1
  74. package/dist/sveltekit/content-routes.js +351 -3
  75. package/dist/sveltekit/tidy-prompt.d.ts +11 -0
  76. package/dist/sveltekit/tidy-prompt.js +118 -0
  77. package/package.json +11 -2
  78. package/src/lib/components/CairnAdmin.svelte +3 -0
  79. package/src/lib/components/CairnTidySettings.svelte +553 -0
  80. package/src/lib/components/EditPage.svelte +371 -2
  81. package/src/lib/components/MarkdownEditor.svelte +168 -1
  82. package/src/lib/components/TidyReview.svelte +463 -0
  83. package/src/lib/components/cairn-admin.css +25 -0
  84. package/src/lib/components/editor-tidy.ts +241 -0
  85. package/src/lib/components/index.ts +1 -0
  86. package/src/lib/components/markdown-directives.ts +35 -0
  87. package/src/lib/components/objective-errors.ts +155 -0
  88. package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
  89. package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
  90. package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
  91. package/src/lib/components/spellcheck-worker.ts +279 -0
  92. package/src/lib/components/spellcheck.ts +693 -0
  93. package/src/lib/components/tidy-categorize.ts +460 -0
  94. package/src/lib/components/tidy-diff.ts +196 -0
  95. package/src/lib/components/tidy-validate.ts +202 -0
  96. package/src/lib/content/compose.ts +11 -1
  97. package/src/lib/content/site-dictionary.ts +84 -0
  98. package/src/lib/content/types.ts +25 -0
  99. package/src/lib/doctor/checks-local.ts +59 -5
  100. package/src/lib/doctor/index.ts +2 -0
  101. package/src/lib/log/events.ts +7 -1
  102. package/src/lib/nav/site-config.ts +197 -0
  103. package/src/lib/sveltekit/admin-dispatch.ts +7 -3
  104. package/src/lib/sveltekit/cairn-admin.ts +32 -4
  105. package/src/lib/sveltekit/content-routes.ts +504 -4
  106. package/src/lib/sveltekit/tidy-prompt.ts +153 -0
@@ -5,304 +5,184 @@ intended use. A component with a schema opens the guided ComponentForm; a templa
5
5
  inserts directly; a component with neither is not listed. Built on a native <dialog> for focus
6
6
  trapping and Escape, following the dropdown's a11y conventions used elsewhere in the admin.
7
7
  -->
8
- <script module lang="ts">
9
- import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
10
-
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 {
19
- return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
20
- }
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
24
- * trigger (the edit page's toolbar) can hide it under the same condition the dialog uses. */
25
- export function insertableDefs(registry?: ComponentRegistry): ComponentDef[] {
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);
8
+ <script module lang="ts">const SEARCH_THRESHOLD = 8;
9
+ export function hasSchema(def) {
10
+ return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
11
+ }
12
+ export function insertableDefs(registry) {
13
+ return (registry?.defs ?? []).filter(
14
+ (def) => (hasSchema(def) || Boolean(def.insertTemplate)) && !def.hidden
15
+ );
16
+ }
17
+ function groupDefs(defs) {
18
+ const order = [];
19
+ const byHeading = /* @__PURE__ */ new Map();
20
+ for (const def of defs) {
21
+ const heading = def.group ?? "";
22
+ if (!byHeading.has(heading)) {
23
+ byHeading.set(heading, []);
24
+ order.push(heading);
51
25
  }
52
- return order.map((heading) => ({ heading, defs: byHeading.get(heading)! }));
26
+ byHeading.get(heading).push(def);
53
27
  }
28
+ return order.map((heading) => ({ heading, defs: byHeading.get(heading) }));
29
+ }
54
30
  </script>
55
31
 
56
- <script lang="ts">
57
- import { tick } from 'svelte';
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';
64
- import ComponentForm from './ComponentForm.svelte';
65
-
66
- interface Props {
67
- /** The site's component registry. */
68
- registry?: ComponentRegistry;
69
- /** Insert markdown at the editor cursor. */
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;
74
- /** The site's icon set, for icon fields. */
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;
84
- /** Disable the trigger; the host sets it while Preview shows. */
85
- disabled?: boolean;
86
- /** Render the built-in Insert block trigger. False mounts only the dialog, for a host that
87
- * supplies its own trigger and opens the dialog through the exported open(). */
88
- trigger?: boolean;
32
+ <script lang="ts">import { tick } from "svelte";
33
+ import { serializeComponent } from "../render/component-grammar.js";
34
+ import { buildPreviewDoc } from "./preview-doc.js";
35
+ import ComponentForm from "./ComponentForm.svelte";
36
+ let { registry, insert, update, icons, render, preview = null, disabled = false, trigger = true } = $props();
37
+ let dialog = $state(null);
38
+ let picked = $state(null);
39
+ let query = $state("");
40
+ let searchInput = $state(null);
41
+ let editValues = $state(null);
42
+ let editRange = $state(null);
43
+ const editing = $derived(editValues !== null);
44
+ let formValues = $state(void 0);
45
+ let formIncomplete = $state(false);
46
+ const twoPane = $derived(Boolean(picked?.preview) && Boolean(render));
47
+ let previewState = $state("settled");
48
+ let previewDoc = $state("");
49
+ const emptyRequired = $derived.by(() => {
50
+ if (!picked || !formValues) return [];
51
+ const out = [];
52
+ for (const field of picked.attributes ?? []) {
53
+ if (!field.required || field.type === "boolean") continue;
54
+ const v = formValues.attributes[field.key];
55
+ if (typeof v !== "string" || v === "") out.push(field.label);
89
56
  }
90
-
91
- let { registry, insert, update, icons, render, preview = null, disabled = false, trigger = true }: Props = $props();
92
-
93
- let dialog = $state<HTMLDialogElement | null>(null);
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
- });
173
-
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;
57
+ for (const slot of picked.slots ?? []) {
58
+ if (!slot.required) continue;
59
+ const v = formValues.slots[slot.name];
60
+ const filled = Array.isArray(v) ? v.some((i) => i !== "") : typeof v === "string" && v !== "";
61
+ if (!filled) out.push(slot.label);
199
62
  }
200
-
201
- /** Open the picker. Exported so a trigger={false} host can drive the dialog itself. */
202
- export function open() {
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());
63
+ return out;
64
+ });
65
+ let previewRun = 0;
66
+ $effect(() => {
67
+ if (!twoPane || !render || !picked || !formValues) return;
68
+ if (formIncomplete) {
69
+ previewState = "settled";
70
+ previewRun++;
71
+ return;
72
+ }
73
+ const md = serializeComponent(picked, formValues);
74
+ const run = ++previewRun;
75
+ previewState = "settling";
76
+ const handle = setTimeout(async () => {
77
+ try {
78
+ const html = await render(md);
79
+ if (run === previewRun) {
80
+ previewDoc = buildPreviewDoc(html, preview);
81
+ previewState = "settled";
82
+ }
83
+ } catch {
84
+ if (run === previewRun) {
85
+ previewState = "failed";
86
+ }
211
87
  }
88
+ }, 200);
89
+ return () => {
90
+ clearTimeout(handle);
91
+ previewRun++;
92
+ };
93
+ });
94
+ const defs = $derived(insertableDefs(registry));
95
+ const showSearch = $derived(defs.length > SEARCH_THRESHOLD);
96
+ const filtered = $derived.by(() => {
97
+ const q = query.trim().toLowerCase();
98
+ if (!q) return defs;
99
+ return defs.filter(
100
+ (def) => def.label.toLowerCase().includes(q) || (def.description ?? "").toLowerCase().includes(q)
101
+ );
102
+ });
103
+ const groups = $derived(groupDefs(filtered));
104
+ function resetPreview() {
105
+ formValues = void 0;
106
+ formIncomplete = false;
107
+ previewState = "settled";
108
+ previewDoc = "";
109
+ editValues = null;
110
+ editRange = null;
111
+ }
112
+ export function open() {
113
+ picked = null;
114
+ query = "";
115
+ resetPreview();
116
+ dialog?.showModal();
117
+ if (showSearch) {
118
+ void tick().then(() => searchInput?.focus());
212
119
  }
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
- ) {
120
+ }
121
+ export function editComponent(def, values, range) {
122
+ resetPreview();
123
+ query = "";
124
+ editValues = values;
125
+ editRange = range;
126
+ picked = def;
127
+ dialog?.showModal();
128
+ }
129
+ function back() {
130
+ picked = null;
131
+ resetPreview();
132
+ }
133
+ function close() {
134
+ picked = null;
135
+ dialog?.close();
136
+ }
137
+ function onClose() {
138
+ picked = null;
139
+ resetPreview();
140
+ }
141
+ function choose(def) {
142
+ if (hasSchema(def)) {
221
143
  resetPreview();
222
- query = '';
223
- editValues = values;
224
- editRange = range;
225
144
  picked = def;
226
- dialog?.showModal();
227
- }
228
-
229
- function back() {
230
- picked = null;
231
- resetPreview();
232
- }
233
- function close() {
234
- picked = null;
235
- dialog?.close();
236
- }
237
- function onClose() {
238
- picked = null;
239
- resetPreview();
240
- }
241
- function choose(def: ComponentDef) {
242
- if (hasSchema(def)) {
243
- resetPreview();
244
- picked = def;
245
- // ComponentForm focuses its own first field on mount.
246
- } else {
247
- insert(def.insertTemplate ?? '');
248
- close();
249
- }
250
- }
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
- }
145
+ } else {
146
+ insert(def.insertTemplate ?? "");
259
147
  close();
260
148
  }
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
- }
149
+ }
150
+ function onSubmit(markdown) {
151
+ if (editing && editRange) {
152
+ update?.(editRange, markdown);
153
+ } else {
154
+ insert(markdown);
273
155
  }
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;
156
+ close();
157
+ }
158
+ function onCancel(e) {
159
+ if (picked && !editing) {
303
160
  e.preventDefault();
304
- rows[isNext ? 0 : rows.length - 1].focus();
161
+ back();
305
162
  }
163
+ }
164
+ function onRowKeydown(e) {
165
+ const isNext = e.key === "ArrowDown" || e.key === "ArrowRight";
166
+ const isPrev = e.key === "ArrowUp" || e.key === "ArrowLeft";
167
+ if (!isNext && !isPrev) return;
168
+ const region = e.currentTarget.closest("[data-cairn-pk-list]");
169
+ if (!region) return;
170
+ const rows = [...region.querySelectorAll('[data-testid="cairn-pk-row"]')];
171
+ const from = rows.indexOf(e.currentTarget);
172
+ if (from < 0) return;
173
+ e.preventDefault();
174
+ const next = (from + (isNext ? 1 : -1) + rows.length) % rows.length;
175
+ rows[next].focus();
176
+ }
177
+ function onSearchKeydown(e) {
178
+ const isNext = e.key === "ArrowDown";
179
+ const isPrev = e.key === "ArrowUp";
180
+ if (!isNext && !isPrev) return;
181
+ const rows = dialog?.querySelectorAll('[data-testid="cairn-pk-row"]');
182
+ if (!rows || rows.length === 0) return;
183
+ e.preventDefault();
184
+ rows[isNext ? 0 : rows.length - 1].focus();
185
+ }
306
186
  </script>
307
187
 
308
188
  <!-- The guided form, identical in either layout: the two-pane case wraps it in the left column, the