astro-dev-edit 0.11.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,308 @@
1
+ import type { ClassifyResult, PeekResponse, SourceLoc } from '../shared/protocol.ts';
2
+ import * as api from './api.ts';
3
+ import { classifyCached } from './classify-cache.ts';
4
+ import { narrowRules, rulesForElement, type MatchedRule } from './css-inspect.ts';
5
+ import { pageSource } from './page-source.ts';
6
+
7
+ /**
8
+ * "Everything we know about this element", assembled into one markdown block to
9
+ * paste into an AI assistant — what the hover pill's `copy ⧉` button produces.
10
+ *
11
+ * The knowledge is deliberately scattered and has to be gathered from three
12
+ * places, none of which can answer for the others:
13
+ * - the **source loc** comes from source-map.ts's snapshot cache (Astro's dev
14
+ * toolbar strips the annotations out of the live DOM, so it can't be read
15
+ * from the element),
16
+ * - the **CSS** comes from the browser's own CSSOM (which knows what applies
17
+ * but not where it was authored), and
18
+ * - the **source text** is only on disk, so it needs the /peek round-trip.
19
+ * Any of the three may come up empty; a section is then dropped or replaced
20
+ * with the reason, and the copy still happens. Never fail the whole payload
21
+ * because one part degraded.
22
+ *
23
+ * Split in two on purpose: `formatContext` is pure (an ElementContext in, a
24
+ * string out) and unit-tested, while `collectContext` is the DOM + fetch half
25
+ * that only the browser can run.
26
+ */
27
+
28
+ /** Cap on the copied outerHTML. Big enough for a real component, small enough
29
+ * that hovering a page wrapper doesn't paste a whole document. */
30
+ const HTML_MAX = 4000;
31
+ /** Cap on copied CSS rules. A heavily-styled element can match dozens, most of
32
+ * them site-wide defaults it merely happens to match — `narrowRules` drops
33
+ * those first, so what survives the cap is what names this element. */
34
+ const RULES_MAX = 12;
35
+ /** Source lines kept either side of the element's own line. /peek returns the
36
+ * whole file (its own cap is ~1000 lines each way); this is the paste-sized
37
+ * window cut out of it.
38
+ *
39
+ * Deliberately tight. The payload's job is to identify *one* element, and a
40
+ * wide window buries it: at ±30 a one-line `<a>` on line 30 of a 107-line file
41
+ * quoted more than half the file, comment blocks and unrelated arrays
42
+ * included, with the three lines that matter near the top. What the window
43
+ * leaves out is named on the section heading, and the whole file is one
44
+ * `/peek` away. */
45
+ const SOURCE_CONTEXT = 5;
46
+ /** Deepest DOM-path segments kept, counting from the element itself. */
47
+ const PATH_MAX = 8;
48
+
49
+ /** The source window actually quoted in the payload. */
50
+ export interface SourceWindow {
51
+ file: string;
52
+ /** 1-based line number of `lines[0]`. */
53
+ startLine: number;
54
+ /** 1-based line the element sits on — marked in the quote. */
55
+ focusLine: number;
56
+ /** Lines in the whole file, so the payload can say what it left out. */
57
+ totalLines: number;
58
+ lines: string[];
59
+ }
60
+
61
+ /** Everything the overlay knows about one element, before formatting. */
62
+ export interface ElementContext {
63
+ loc: SourceLoc;
64
+ /** The opening tag as rendered, e.g. `<h1 class="hero-title">`. */
65
+ openTag: string;
66
+ /** Short label for toasts/titles, e.g. `h1.hero-title`. */
67
+ label: string;
68
+ /** What a click would do, per the server's AST classification; null when it
69
+ * couldn't be determined. Deliberately *not* rendered into the copied
70
+ * markdown: it describes what this overlay can edit, not the element, and
71
+ * an LLM reads it as a constraint on what it may change. */
72
+ verdict: string | null;
73
+ pageUrl: string;
74
+ /** Content-collection file backing the page, when it declares one. */
75
+ entryFile: string | null;
76
+ domPath: string;
77
+ html: string;
78
+ /** Characters the cap dropped from `html`; 0 when it's complete. */
79
+ htmlDropped: number;
80
+ rules: MatchedRule[];
81
+ /** Rules the cap dropped; 0 when all matched rules are present. */
82
+ rulesDropped: number;
83
+ source: SourceWindow | null;
84
+ /** Why `source` is null (a server refusal or a failed read), when it is. */
85
+ sourceUnavailable: string | null;
86
+ }
87
+
88
+ // --- Formatting (pure) -------------------------------------------------------
89
+
90
+ /** Fence language for the source quote, by extension. */
91
+ function fenceLang(file: string): string {
92
+ const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
93
+ if (ext === 'astro') return 'astro';
94
+ if (ext === 'md' || ext === 'mdx' || ext === 'markdown') return 'markdown';
95
+ if (ext === 'html') return 'html';
96
+ if (ext === 'ts' || ext === 'tsx') return 'ts';
97
+ if (ext === 'js' || ext === 'jsx') return 'js';
98
+ return '';
99
+ }
100
+
101
+ /** `>` gutter-marks the element's own line, so the model knows which of the
102
+ * quoted lines is the subject without a marker polluting the code text. */
103
+ function quoteSource(source: SourceWindow): string {
104
+ const last = source.startLine + source.lines.length - 1;
105
+ const width = String(last).length;
106
+ return source.lines
107
+ .map((line, i) => {
108
+ const no = source.startLine + i;
109
+ const mark = no === source.focusLine ? '>' : ' ';
110
+ return `${mark} ${String(no).padStart(width)} | ${line}`;
111
+ })
112
+ .join('\n');
113
+ }
114
+
115
+ function ruleBlock(rule: MatchedRule): string {
116
+ const body = rule.declarations
117
+ .split('\n')
118
+ .map((d) => ` ${d}`)
119
+ .join('\n');
120
+ const from = rule.sourceFile ? `/* ${rule.sourceFile} */\n` : '';
121
+ return `${from}${rule.selectorText} {\n${body}\n}`;
122
+ }
123
+
124
+ /** Render an ElementContext as the markdown that lands on the clipboard.
125
+ * Every section is optional — an element with no classes has no CSS block, a
126
+ * refused /peek has no source block. */
127
+ export function formatContext(ctx: ElementContext): string {
128
+ const where = `${ctx.loc.file}:${ctx.loc.loc}`;
129
+ const out: string[] = [`# Element context — ${where}`, ''];
130
+
131
+ out.push(`- **Element** \`${ctx.openTag}\``);
132
+ out.push(`- **Source** ${where}`);
133
+ out.push(`- **Page** ${ctx.pageUrl}`);
134
+ if (ctx.entryFile) out.push(`- **Content entry** ${ctx.entryFile}`);
135
+ out.push(`- **DOM path** ${ctx.domPath}`, '');
136
+
137
+ out.push('## Rendered HTML', '```html', ctx.html, '```');
138
+ if (ctx.htmlDropped > 0) {
139
+ out.push(`_Truncated — ${ctx.htmlDropped} more characters of markup._`);
140
+ }
141
+ out.push('');
142
+
143
+ if (ctx.source) {
144
+ const { startLine, lines, totalLines, file } = ctx.source;
145
+ const last = startLine + lines.length - 1;
146
+ const range = lines.length === totalLines ? `all ${totalLines} lines` : `lines ${startLine}–${last} of ${totalLines}`;
147
+ out.push(`## Source — ${file} (${range}, \`>\` marks the element)`);
148
+ const lang = fenceLang(file);
149
+ out.push(`\`\`\`${lang}`, quoteSource(ctx.source), '```', '');
150
+ } else if (ctx.sourceUnavailable) {
151
+ out.push('## Source', `_Not available — ${ctx.sourceUnavailable}_`, '');
152
+ }
153
+
154
+ if (ctx.rules.length > 0) {
155
+ const shown = ctx.rules.length;
156
+ const total = shown + ctx.rulesDropped;
157
+ const count = ctx.rulesDropped > 0 ? `${shown} of ${total} rules` : `${shown} rule${shown === 1 ? '' : 's'}`;
158
+ out.push(`## CSS that applies (${count})`, '```css');
159
+ out.push(ctx.rules.map(ruleBlock).join('\n\n'));
160
+ out.push('```');
161
+ if (ctx.rulesDropped > 0) out.push(`_Truncated — ${ctx.rulesDropped} further matching rules._`);
162
+ out.push('');
163
+ } else {
164
+ out.push('## CSS that applies', '_No stylesheet rule matches this element directly (it may inherit from an ancestor, or its stylesheets are cross-origin)._', '');
165
+ }
166
+
167
+ return `${out.join('\n').trimEnd()}\n`;
168
+ }
169
+
170
+ /**
171
+ * Source paths repo-relative, the shape a person (or an assistant) can act on.
172
+ *
173
+ * Astro's `data-astro-source-file` annotations are absolute fsPaths — the pill
174
+ * only ever showed their basename, so it never mattered before, but
175
+ * "/Users/you/projects/site/src/pages/index.astro:12:3" is noise in a paste.
176
+ * `root` comes from /health; without it the path is left as-is rather than
177
+ * guessed at.
178
+ */
179
+ export function relativize(file: string, root: string | null): string {
180
+ const path = file.replace(/\\/g, '/');
181
+ if (!root) return path;
182
+ const base = root.replace(/\\/g, '/').replace(/\/+$/, '') + '/';
183
+ return path.startsWith(base) ? path.slice(base.length) : path;
184
+ }
185
+
186
+ /** Cut the paste-sized window out of a /peek response (which is normally the
187
+ * whole file). Exported for its own test — the arithmetic is 1-based and easy
188
+ * to get wrong by one. */
189
+ export function windowAround(peeked: PeekResponse, context = SOURCE_CONTEXT): SourceWindow {
190
+ const first = peeked.startLine;
191
+ const last = first + peeked.lines.length - 1;
192
+ const from = Math.max(first, peeked.focusLine - context);
193
+ const to = Math.min(last, peeked.focusLine + context);
194
+ return {
195
+ file: peeked.file,
196
+ startLine: from,
197
+ focusLine: peeked.focusLine,
198
+ totalLines: peeked.totalLines,
199
+ lines: peeked.lines.slice(from - first, to - first + 1),
200
+ };
201
+ }
202
+
203
+ // --- Collection (DOM + server) -----------------------------------------------
204
+
205
+ /** Astro's own scoping class is machine-generated noise in a path label. */
206
+ function authoredClass(el: Element): string | undefined {
207
+ return Array.from(el.classList).find((c) => !/^astro-[\w-]+$/.test(c));
208
+ }
209
+
210
+ /** `h1.hero-title` / `section#masthead` / `div` — one path segment. */
211
+ function describe(el: Element): string {
212
+ const tag = el.tagName.toLowerCase();
213
+ if (el.id) return `${tag}#${el.id}`;
214
+ const cls = authoredClass(el);
215
+ return cls ? `${tag}.${cls}` : tag;
216
+ }
217
+
218
+ function domPathOf(el: HTMLElement): string {
219
+ const parts: string[] = [];
220
+ let cur: HTMLElement | null = el;
221
+ while (cur && cur !== document.documentElement) {
222
+ parts.unshift(describe(cur));
223
+ cur = cur.parentElement;
224
+ }
225
+ if (parts.length > PATH_MAX) return `… > ${parts.slice(-PATH_MAX).join(' > ')}`;
226
+ return parts.join(' > ');
227
+ }
228
+
229
+ /** The opening tag only, attributes included, as the browser renders it. */
230
+ function openTagOf(el: HTMLElement): string {
231
+ const shallow = el.cloneNode(false) as HTMLElement;
232
+ const end = shallow.outerHTML.indexOf('>');
233
+ return end === -1 ? `<${el.tagName.toLowerCase()}>` : shallow.outerHTML.slice(0, end + 1);
234
+ }
235
+
236
+ /** outerHTML, capped. Nothing of the overlay's can appear in it: every panel,
237
+ * outline and veil lives in the shadow root, whose host is a child of <body>
238
+ * and never of the element being copied. */
239
+ function renderedHtml(el: HTMLElement): { html: string; dropped: number } {
240
+ const full = el.outerHTML;
241
+ if (full.length <= HTML_MAX) return { html: full, dropped: 0 };
242
+ return { html: full.slice(0, HTML_MAX), dropped: full.length - HTML_MAX };
243
+ }
244
+
245
+ /** Phrase the AST classification the way the payload reads best. */
246
+ function verdictOf(result: ClassifyResult): string {
247
+ const what =
248
+ result.kind === 'text'
249
+ ? 'editable text'
250
+ : result.kind === 'image'
251
+ ? 'image (src/alt)'
252
+ : `not editable in place (${result.kind})`;
253
+ return `${what} — ${result.reason}`;
254
+ }
255
+
256
+ async function sourceFor(
257
+ src: SourceLoc,
258
+ root: string | null,
259
+ ): Promise<Pick<ElementContext, 'source' | 'sourceUnavailable'>> {
260
+ try {
261
+ const peeked = await api.peek({ file: src.file, loc: src.loc });
262
+ // Not an error: the file is real but package-owned (an astro:assets
263
+ // <Image>, say), so the server explains instead of returning source.
264
+ if (peeked.refused) return { source: null, sourceUnavailable: peeked.refused };
265
+ const window = windowAround(peeked);
266
+ return { source: { ...window, file: relativize(window.file, root) }, sourceUnavailable: null };
267
+ } catch (err) {
268
+ return {
269
+ source: null,
270
+ sourceUnavailable: err instanceof Error ? err.message : 'the source file could not be read',
271
+ };
272
+ }
273
+ }
274
+
275
+ /** Gather everything for one element. Only the /peek read can be slow; the
276
+ * classification is served from the hover cache in the normal case. `root` is
277
+ * the project root from /health, used to render source paths repo-relative. */
278
+ export async function collectContext(
279
+ el: HTMLElement,
280
+ src: SourceLoc,
281
+ root: string | null,
282
+ ): Promise<ElementContext> {
283
+ const { html, dropped } = renderedHtml(el);
284
+ const matched = rulesForElement(el);
285
+ const kept = narrowRules(el, matched, RULES_MAX);
286
+
287
+ let verdict: string | null = null;
288
+ try {
289
+ verdict = verdictOf(await classifyCached({ file: src.file, loc: src.loc, tag: el.tagName.toLowerCase() }));
290
+ } catch {
291
+ // Classification is a nicety here — the rest of the context still stands.
292
+ }
293
+
294
+ return {
295
+ loc: { file: relativize(src.file, root), loc: src.loc },
296
+ openTag: openTagOf(el),
297
+ label: describe(el),
298
+ verdict,
299
+ pageUrl: location.href,
300
+ entryFile: pageSource(),
301
+ domPath: domPathOf(el),
302
+ html,
303
+ htmlDropped: dropped,
304
+ rules: kept,
305
+ rulesDropped: matched.length - kept.length,
306
+ ...(await sourceFor(src, root)),
307
+ };
308
+ }
@@ -0,0 +1,81 @@
1
+ import type { HealthResponse, UnsplashImportWidth } from '../shared/protocol.ts';
2
+ import { UNSPLASH_DEFAULT_IMPORT_WIDTH, coerceImportWidth } from '../shared/unsplash.ts';
3
+
4
+ /**
5
+ * Server-derived feature state, set at boot from `/health` (and refreshed by a
6
+ * Settings save) — the on/off flags, plus the one resolved value a surface
7
+ * needs before its own endpoint has answered.
8
+ *
9
+ * A **leaf module** on purpose: the media modal needs to know whether the
10
+ * Unsplash source is available, and reading that from `overlay.ts` would create
11
+ * an `overlay → router → image → media-modal → overlay` import cycle. Anything
12
+ * that only needs to *read* a flag imports this instead.
13
+ *
14
+ * Flags default to off, so a server that predates a flag (or a failed health
15
+ * check) degrades to the feature being absent rather than to a surface that
16
+ * errors when touched.
17
+ */
18
+
19
+ interface Features {
20
+ /** The Unsplash photo source is enabled AND the server holds a usable key.
21
+ * False means the media modal renders single-source, with no tab strip. */
22
+ unsplash: boolean;
23
+ /** The resolved `unsplash.importWidth`, where the picker's size select starts.
24
+ * The one non-boolean here: it is server-derived and refreshed by the same
25
+ * two calls, so a separate channel for it would be a second thing to keep in
26
+ * step for no gain. */
27
+ unsplashImportWidth: UnsplashImportWidth;
28
+ /** The hover pill's class/ID chips and their CSS rules. */
29
+ cssInspector: boolean;
30
+ /** The "Open source" buttons and jump-to-file links. */
31
+ openInEditor: boolean;
32
+ /** The CMS entry drawer and the admin bar's entry button. */
33
+ entryEditor: boolean;
34
+ }
35
+
36
+ const features: Features = {
37
+ unsplash: false,
38
+ unsplashImportWidth: UNSPLASH_DEFAULT_IMPORT_WIDTH,
39
+ cssInspector: false,
40
+ openInEditor: false,
41
+ entryEditor: false,
42
+ };
43
+
44
+ /** Called once from `overlay.ts`'s boot, with the /health payload. */
45
+ export function setFeatures(info: HealthResponse): void {
46
+ features.unsplash = info.unsplash === true;
47
+ // A server that predates the option says nothing — keep the default rather
48
+ // than resolving to a width it would not honour.
49
+ features.unsplashImportWidth =
50
+ coerceImportWidth(info.unsplashImportWidth) ?? UNSPLASH_DEFAULT_IMPORT_WIDTH;
51
+ features.cssInspector = info.cssInspector === true;
52
+ features.openInEditor = info.openInEditor === true;
53
+ features.entryEditor = info.entryEditor === true;
54
+ }
55
+
56
+ /** Re-read a single flag — used after the Settings panel stores a key, so the
57
+ * Unsplash tab appears without a page reload. */
58
+ export function updateFeature<K extends keyof Features>(key: K, value: Features[K]): void {
59
+ features[key] = value;
60
+ }
61
+
62
+ export function hasUnsplash(): boolean {
63
+ return features.unsplash;
64
+ }
65
+
66
+ /** Where the picker's size select starts — `has()` is boolean-typed, so the one
67
+ * non-boolean gets its own reader. */
68
+ export function unsplashImportWidth(): UnsplashImportWidth {
69
+ return features.unsplashImportWidth;
70
+ }
71
+
72
+ /** Keys of {@link Features} that are on/off. Narrowed rather than left as
73
+ * `keyof Features` so `has('unsplashImportWidth')` is a type error instead of
74
+ * a truthiness test on a width. */
75
+ type FeatureFlag = {
76
+ [K in keyof Features]: Features[K] extends boolean ? K : never;
77
+ }[keyof Features];
78
+
79
+ export function has(key: FeatureFlag): boolean {
80
+ return features[key];
81
+ }
@@ -0,0 +1,166 @@
1
+ import { overlayActiveElement } from './shadow.ts';
2
+
3
+ /**
4
+ * Modal focus containment — the keyboard half of what a backdrop does for the
5
+ * pointer.
6
+ *
7
+ * Every panel and drawer here is a `<div>` over a click-swallowing backdrop.
8
+ * That stops the pointer and nothing else: Tab walked straight out of the
9
+ * open panel into the site's navigation, the page's own links, Astro's dev
10
+ * toolbar and the admin bar behind it, with the panel still up. It reads as a
11
+ * modal and behaves as an overlay.
12
+ *
13
+ * `trapFocus` gives a shell the semantics (`role="dialog"`, `aria-modal`) and
14
+ * the behaviour (Tab cycles inside it; focus that lands outside is pulled
15
+ * back; the opener gets focus again on release).
16
+ *
17
+ * Two things make it more than a `querySelectorAll` loop:
18
+ *
19
+ * - **The composed tree, not the shadow tree.** The rich-text editor's
20
+ * `contenteditable` is a light-DOM node `<slot>`-ed into the drawer (see
21
+ * `shadow.ts::mountLight`), so `shell.querySelectorAll` cannot see it and a
22
+ * trap built on that query would lock the user out of the body they came to
23
+ * write. Slots are followed, in slot order, so Tab order matches what the eye
24
+ * sees.
25
+ * - **Traps stack.** The media modal opens over the CMS drawer, and the
26
+ * Settings drawer over the media modal. Only the innermost trap acts; the one
27
+ * underneath resumes when it is released, exactly like `state.releaseTo`.
28
+ */
29
+
30
+ const FOCUSABLE =
31
+ 'a[href], button, input, select, textarea, [contenteditable], [tabindex]';
32
+
33
+ const stack: Trap[] = [];
34
+
35
+ interface Trap {
36
+ shell: HTMLElement;
37
+ opener: Element | null;
38
+ }
39
+
40
+ function focusable(el: Element): el is HTMLElement {
41
+ if (!el.matches(FOCUSABLE)) return false;
42
+ if ((el as HTMLInputElement).disabled) return false;
43
+ if (el.getAttribute('tabindex') === '-1') return false;
44
+ if (el.hasAttribute('contenteditable') && !(el as HTMLElement).isContentEditable) return false;
45
+ // Hidden things are not in the tab order; a zero-size box is the only
46
+ // reliable read of that from here (the panel itself is never display:none).
47
+ return (el as HTMLElement).getClientRects().length > 0;
48
+ }
49
+
50
+ /** Focusable descendants in composed order, following `<slot>`s. */
51
+ function focusablesIn(node: Element, out: HTMLElement[] = []): HTMLElement[] {
52
+ for (const child of Array.from(node.children)) {
53
+ if (child instanceof HTMLSlotElement) {
54
+ for (const assigned of child.assignedElements()) {
55
+ if (focusable(assigned)) out.push(assigned);
56
+ focusablesIn(assigned, out);
57
+ }
58
+ continue;
59
+ }
60
+ if (focusable(child)) out.push(child);
61
+ focusablesIn(child, out);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Whether an event happened inside `shell`, slotted content included. */
67
+ function inShell(shell: HTMLElement, e: Event): boolean {
68
+ return e.composedPath().includes(shell);
69
+ }
70
+
71
+ function top(): Trap | undefined {
72
+ return stack[stack.length - 1];
73
+ }
74
+
75
+ function onKeyDown(e: KeyboardEvent): void {
76
+ if (e.key !== 'Tab') return;
77
+ const trap = top();
78
+ if (!trap) return;
79
+ const items = focusablesIn(trap.shell);
80
+ if (items.length === 0) {
81
+ e.preventDefault();
82
+ return;
83
+ }
84
+ const first = items[0] as HTMLElement;
85
+ const last = items[items.length - 1] as HTMLElement;
86
+ const active = e.composedPath()[0];
87
+ const here = inShell(trap.shell, e);
88
+ if (!here) {
89
+ // Focus is already outside (a stray click on the page, say) — Tab brings
90
+ // it back rather than continuing the page's own order.
91
+ e.preventDefault();
92
+ first.focus();
93
+ return;
94
+ }
95
+ if (e.shiftKey && active === first) {
96
+ e.preventDefault();
97
+ last.focus();
98
+ } else if (!e.shiftKey && active === last) {
99
+ e.preventDefault();
100
+ first.focus();
101
+ }
102
+ }
103
+
104
+ function onFocusIn(e: FocusEvent): void {
105
+ const trap = top();
106
+ if (!trap) return;
107
+ if (inShell(trap.shell, e)) return;
108
+ // Focus reached the page behind the backdrop — from the browser's own
109
+ // address-bar cycle, or a script. Take it back.
110
+ const items = focusablesIn(trap.shell);
111
+ (items[0] ?? trap.shell).focus();
112
+ }
113
+
114
+ function listen(on: boolean): void {
115
+ const fn = on ? document.addEventListener : document.removeEventListener;
116
+ fn.call(document, 'keydown', onKeyDown as EventListener, true);
117
+ fn.call(document, 'focusin', onFocusIn as EventListener, true);
118
+ }
119
+
120
+ export interface TrapOptions {
121
+ /** Accessible name for the dialog. Defaults to the shell's own title text. */
122
+ label?: string;
123
+ /** Focus this instead of the first focusable when the trap opens. Pass null
124
+ * to leave focus where the caller already put it. */
125
+ initial?: HTMLElement | null;
126
+ }
127
+
128
+ /**
129
+ * Make `shell` a modal dialog and hold focus inside it. Returns the release,
130
+ * which restores focus to whatever was focused when the trap opened.
131
+ */
132
+ export function trapFocus(shell: HTMLElement, opts: TrapOptions = {}): () => void {
133
+ shell.setAttribute('role', 'dialog');
134
+ shell.setAttribute('aria-modal', 'true');
135
+ const label =
136
+ opts.label ??
137
+ shell.querySelector('.atx-panel-heading, .atx-drawer-title-text')?.textContent ??
138
+ '';
139
+ if (label) shell.setAttribute('aria-label', label);
140
+ // So focus has somewhere to land in a shell whose controls are all disabled
141
+ // mid-save, without putting the panel itself in the tab order.
142
+ if (!shell.hasAttribute('tabindex')) shell.tabIndex = -1;
143
+
144
+ // `document.activeElement` reports the host for anything inside the overlay,
145
+ // so the opener has to be read through the root to be restorable.
146
+ const trap: Trap = { shell, opener: overlayActiveElement() ?? document.activeElement };
147
+ stack.push(trap);
148
+ if (stack.length === 1) listen(true);
149
+
150
+ if (opts.initial !== null) {
151
+ const target = opts.initial ?? focusablesIn(shell)[0] ?? shell;
152
+ target.focus();
153
+ }
154
+
155
+ let released = false;
156
+ return () => {
157
+ if (released) return;
158
+ released = true;
159
+ const i = stack.indexOf(trap);
160
+ if (i >= 0) stack.splice(i, 1);
161
+ if (stack.length === 0) listen(false);
162
+ const opener = trap.opener;
163
+ if (opener instanceof HTMLElement && opener.isConnected) opener.focus();
164
+ };
165
+ }
166
+