@keenmate/web-components-devkit 1.0.0-rc02 → 1.0.0-rc04

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@keenmate/web-components-devkit/examples` — named exports for example pages.
3
- * The page-level helpers (styles, chapter nav, copy-links, version badge,
4
- * theme switch) are mounted automatically by the Vite plugin; import from here
3
+ * The page-level helpers (styles, chapter nav, spotlight, copy-links, version
4
+ * badge, theme switch) are mounted automatically by the Vite plugin; import from here
5
5
  * only for the opt-in bits (controls persistence, index page) or to drive a
6
6
  * helper manually.
7
7
  */
@@ -9,10 +9,13 @@ export { attachControlsPersistence, getControlsStorageKey } from './controls-per
9
9
  export { renderExamplesIndex } from './index-page.js';
10
10
  export { mountExamplesKit } from './kit.js';
11
11
  export { mountChapterNav } from './chapter-nav.js';
12
+ export { mountSpotlight } from './spotlight.js';
13
+ export { attachChapterLinks, scrollToChapterHash } from './chapter-links.js';
14
+ export { searchChapters, checkMatchesAny, renderHighlightedText } from './chapter-search.js';
12
15
  export { attachCopyLinks, copyText, getSectionUrl } from './copy-link.js';
13
16
  export { renderVersionBadges, getVersionLabel } from './version-badge.js';
14
17
  export { attachDemoCopyButtons } from './demo-code.js';
15
18
  export { mountThemeSwitch, applyTheme, getSavedTheme } from './theme-switch.js';
16
- export { ensureSectionAnchors, generateSlug, getHeadingLabel } from './sections.js';
19
+ export { ensureSectionAnchors, generateSlug, getHeadingLabel, getChapterCode, mapToCodeKey, findSectionByHash } from './sections.js';
17
20
  export { getPageConfig, attachDomReadyHandler } from './page-config.js';
18
21
  export * from './constants.js';
@@ -1,4 +1,6 @@
1
1
  import { mountChapterNav } from './chapter-nav.js';
2
+ import { attachChapterLinks } from './chapter-links.js';
3
+ import { mountSpotlight } from './spotlight.js';
2
4
  import { attachCopyLinks } from './copy-link.js';
3
5
  import { renderVersionBadges } from './version-badge.js';
4
6
  import { mountThemeSwitch } from './theme-switch.js';
@@ -6,16 +8,21 @@ import { attachDemoCopyButtons } from './demo-code.js';
6
8
  import { INDEX_PAGE_CLASSES } from './constants.js';
7
9
 
8
10
  /**
9
- * (Re)mount the page-level helpers on the current DOM. Runs automatically once
10
- * per page; call it again after rendering sections asynchronously. Idempotent:
11
- * copy-link / demo copy buttons skip elements that already have one, the nav
12
- * and the theme switch are remounted.
11
+ * (Re)mount the page-level helpers on the current DOM (copy-links, chapter
12
+ * nav, short chapter links, spotlight, version badges, demo copy buttons,
13
+ * theme switch). Runs automatically once per page; call it again after
14
+ * rendering sections asynchronously. Idempotent: copy-link / demo copy buttons
15
+ * skip elements that already have one, the nav, spotlight and theme switch are
16
+ * remounted.
13
17
  */
14
18
  export function mountExamplesKit() {
15
19
  // Copy-links before the nav: the nav strips the buttons back out when
16
20
  // reading labels, and this order avoids a relayout after the nav is built.
17
21
  if (!document.body.classList.contains(INDEX_PAGE_CLASSES.body)) attachCopyLinks();
18
22
  mountChapterNav();
23
+ // After the anchors exist: an arriving #th07 can only resolve now.
24
+ attachChapterLinks();
25
+ mountSpotlight();
19
26
  renderVersionBadges();
20
27
  attachDemoCopyButtons();
21
28
  mountThemeSwitch();
@@ -16,6 +16,7 @@ export function getPageConfig() {
16
16
  mode: injectedConfig.mode || 'dev',
17
17
  canonicalOrigin: injectedConfig.canonicalOrigin || '',
18
18
  storagePrefix: injectedConfig.storagePrefix || injectedConfig.id || 'component',
19
+ isSpotlightEnabled: injectedConfig.isSpotlightEnabled ?? true,
19
20
  repository: injectedConfig.repository || '',
20
21
  homepage: injectedConfig.homepage || '',
21
22
  devkitVersion: injectedConfig.devkitVersion || '',
@@ -8,7 +8,7 @@
8
8
  * reusing an existing section/h2 id before slugging the heading text.
9
9
  * Idempotent: a second call returns the same ids.
10
10
  */
11
- import { HEADING_CHIP_SELECTOR, SECTION_CONTAINER_SELECTOR, SECTION_SELECTOR } from './constants.js';
11
+ import { CHAPTER_CODE_PATTERN, HEADING_CHIP_SELECTOR, SECTION_CONTAINER_SELECTOR, SECTION_SELECTOR } from './constants.js';
12
12
 
13
13
  export function generateSlug(text) {
14
14
  return String(text)
@@ -51,3 +51,40 @@ export function ensureSectionAnchors(root = document) {
51
51
  return { heading, section, id, label };
52
52
  });
53
53
  }
54
+
55
+ /** `TH07 · Title` → 'TH07'; an uncoded label (`Summary`) → null. */
56
+ export function getChapterCode(label) {
57
+ return String(label).match(CHAPTER_CODE_PATTERN)?.[1] ?? null;
58
+ }
59
+
60
+ /** Comparison key for a code: case and leading zeros don't matter (`TH07`, `th7` → 'th7'). */
61
+ export function mapToCodeKey(code) {
62
+ return String(code).toLowerCase().replace(/^([a-z]+)0+(?=\d)/, '$1');
63
+ }
64
+
65
+ /**
66
+ * The section a URL hash points at: its anchor id, else its section code
67
+ * (`#th07`, `#th7` — the short link the codes exist for), else the slug of its
68
+ * heading text (an uncoded chapter whose section carries its own id).
69
+ * @param {string} hash `#…` or the bare fragment
70
+ * @param {ReturnType<typeof ensureSectionAnchors>} sections
71
+ */
72
+ export function findSectionByHash(hash, sections) {
73
+ let fragment = String(hash).replace(/^#/, '');
74
+ try {
75
+ fragment = decodeURIComponent(fragment);
76
+ } catch {
77
+ // a malformed escape — match it as written
78
+ }
79
+ if (!fragment) return null;
80
+ const fragmentKey = mapToCodeKey(fragment);
81
+ return (
82
+ sections.find((section) => section.id === fragment) ??
83
+ sections.find((section) => {
84
+ const code = getChapterCode(section.label);
85
+ return code !== null && mapToCodeKey(code) === fragmentKey;
86
+ }) ??
87
+ sections.find((section) => generateSlug(section.label) === fragment.toLowerCase()) ??
88
+ null
89
+ );
90
+ }
@@ -0,0 +1,473 @@
1
+ /**
2
+ * SPOTLIGHT — search every chapter of every example page and jump to it.
3
+ *
4
+ * MIRRORS web-commandpalette (wcp) without depending on it: the shell markup
5
+ * and class names, the row / section / footer markup, the keyboard map, and
6
+ * open / close (focus return, body scroll lock, phone fullscreen sheet). Every
7
+ * block marked `mirrors …` keeps the wcp structure line for line, in devkit
8
+ * naming — scripts/spotlight-mirror.json records the wcp commit it is aligned
9
+ * to and `npm run check:spotlight-mirror` lists what changed there since. A bug
10
+ * fixed in one gets ported to the other.
11
+ *
12
+ * Spotlight-only: a single fixed list (no modes, wizards, providers, usage),
13
+ * rows are links (Ctrl/⌘+Enter or a modified click opens a new tab), the
14
+ * header "Search" trigger button, and the chapter data: the Vite plugin's
15
+ * site-wide index (vite/chapter-index.js, imported on first use) with the
16
+ * current page's chapters read live from the DOM.
17
+ *
18
+ * Ctrl/⌘+K toggles. Off with package.json#kmwc.examples.isSpotlightEnabled =
19
+ * false. Styles: css/spotlight.css.
20
+ */
21
+ import { ensureSectionAnchors, getChapterCode } from './sections.js';
22
+ import { searchChapters, renderHighlightedText, mapToEscapedHtml } from './chapter-search.js';
23
+ import { scrollToChapterHash } from './chapter-links.js';
24
+ import { getPageConfig } from './page-config.js';
25
+ import {
26
+ CHAPTER_CODE_PATTERN,
27
+ SPOTLIGHT_CLASSES,
28
+ SPOTLIGHT_FULLSCREEN_MEDIA,
29
+ SPOTLIGHT_ITEM_ID_PREFIX,
30
+ SPOTLIGHT_OPEN_KEY,
31
+ SPOTLIGHT_PAGE_STEP,
32
+ SPOTLIGHT_RESULTS_ID,
33
+ } from './constants.js';
34
+
35
+ const LABELS = {
36
+ dialogLabel: 'Search chapters',
37
+ placeholder: 'Search chapters — code (TH07) or title',
38
+ close: 'Close',
39
+ navigate: 'Navigate',
40
+ jump: 'Jump',
41
+ newTab: 'New tab',
42
+ noResults: 'No chapter matches “{query}”',
43
+ noChapters: 'No chapters indexed',
44
+ resultsCount: '{count} chapters',
45
+ thisPage: '{page} · this page',
46
+ };
47
+
48
+ const SEARCH_ICON_HTML =
49
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
50
+ '<circle cx="11" cy="11" r="7"></circle><line x1="16.5" y1="16.5" x2="21" y2="21"></line>' +
51
+ '</svg>';
52
+ const CLOSE_ICON_HTML =
53
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><line x1="6" y1="6" x2="18" y2="18"></line><line x1="18" y1="6" x2="6" y2="18"></line></svg>';
54
+
55
+ const getItemId = (index) => `${SPOTLIGHT_ITEM_ID_PREFIX}${index}`;
56
+ const fillLabel = (template, values) => template.replace(/\{(\w+)\}/g, (placeholder, name) => (name in values ? String(values[name]) : placeholder));
57
+
58
+ // ── shared helpers (mirrors) ─────────────────────────────────────────────────
59
+
60
+ // mirrors web-commandpalette src/render.ts isMac()
61
+ export const checkIsMac = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
62
+
63
+ /** Keycap text for a Ctrl/⌘ + letter shortcut: `Ctrl K` / `⌘K`. */
64
+ // mirrors web-commandpalette src/render.ts chordKeycap()
65
+ export const getChordKeycap = (letter) => (!letter ? '' : checkIsMac() ? `⌘${letter.toUpperCase()}` : `Ctrl ${letter.toUpperCase()}`);
66
+
67
+ /** Is the (shadow-piercing) event origin a text-entry field? */
68
+ // mirrors web-commandpalette src/keyboard.ts isTypingTarget()
69
+ export function checkIsTypingTarget(event) {
70
+ const target = event.composedPath?.()[0] ?? event.target;
71
+ if (!target || !(target instanceof Element)) return false;
72
+ const tagName = target.tagName;
73
+ return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' || target.isContentEditable;
74
+ }
75
+
76
+ /** The new active index after a move. Arrows wrap, the rest clamp. */
77
+ // mirrors web-commandpalette src/palette.ts PaletteEngine.move()
78
+ export function getMovedIndex(activeIndex, length, direction) {
79
+ if (!length) return activeIndex;
80
+ const fromIndex = activeIndex < 0 ? 0 : activeIndex;
81
+ switch (direction) {
82
+ case 'down': return activeIndex >= length - 1 ? 0 : activeIndex + 1;
83
+ case 'up': return activeIndex <= 0 ? length - 1 : activeIndex - 1;
84
+ case 'pageDown': return Math.min(length - 1, fromIndex + SPOTLIGHT_PAGE_STEP);
85
+ case 'pageUp': return Math.max(0, fromIndex - SPOTLIGHT_PAGE_STEP);
86
+ case 'home': return 0;
87
+ case 'end': return length - 1;
88
+ }
89
+ return activeIndex;
90
+ }
91
+
92
+ const KEY_DIRECTIONS = { ArrowDown: 'down', ArrowUp: 'up', PageDown: 'pageDown', PageUp: 'pageUp', Home: 'home', End: 'end' };
93
+
94
+ // mirrors web-components-core menu/open-menu.ts deepActiveElement()
95
+ function getDeepActiveElement() {
96
+ let activeElement = document.activeElement;
97
+ while (activeElement?.shadowRoot?.activeElement) activeElement = activeElement.shadowRoot.activeElement;
98
+ return activeElement;
99
+ }
100
+
101
+ let scrollLockCount = 0;
102
+ let stashedOverflow = null;
103
+ /** Lock <body> scrolling; returns the release (idempotent). */
104
+ // mirrors web-components-core overlay/scroll-lock.ts lockBodyScroll()
105
+ function lockBodyScroll() {
106
+ if (scrollLockCount === 0) {
107
+ stashedOverflow = document.body.style.overflow;
108
+ document.body.style.overflow = 'hidden';
109
+ }
110
+ scrollLockCount++;
111
+ let isReleased = false;
112
+ return () => {
113
+ if (isReleased) return;
114
+ isReleased = true;
115
+ scrollLockCount = Math.max(0, scrollLockCount - 1);
116
+ if (scrollLockCount === 0 && stashedOverflow !== null) {
117
+ document.body.style.overflow = stashedOverflow;
118
+ stashedOverflow = null;
119
+ }
120
+ };
121
+ }
122
+
123
+ // ── markup (mirrors) ─────────────────────────────────────────────────────────
124
+
125
+ /** The dialog shell. */
126
+ // mirrors web-commandpalette src/render.ts createShell() (no tokens / context pill / confirmation)
127
+ function createShell() {
128
+ const classes = SPOTLIGHT_CLASSES;
129
+ const root = document.createElement('div');
130
+ root.className = classes.root;
131
+ root.innerHTML = `
132
+ <div class="${classes.backdrop}"></div>
133
+ <div class="${classes.container}" role="dialog" aria-modal="true" aria-label="${LABELS.dialogLabel}">
134
+ <div class="${classes.fullscreenBar}">
135
+ <span class="${classes.fullscreenTitle}">${LABELS.dialogLabel}</span>
136
+ <button type="button" class="${classes.close}" aria-label="${LABELS.close}">${CLOSE_ICON_HTML}</button>
137
+ </div>
138
+ <div class="${classes.search}">
139
+ <div class="${classes.inputWrapper}">
140
+ <input type="text" class="${classes.input}" autocomplete="off" spellcheck="false" placeholder="${LABELS.placeholder}"
141
+ aria-label="${LABELS.dialogLabel}" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="${SPOTLIGHT_RESULTS_ID}">
142
+ </div>
143
+ </div>
144
+ <div class="${classes.results}" id="${SPOTLIGHT_RESULTS_ID}" role="listbox"></div>
145
+ <div class="${classes.footer}"></div>
146
+ <div class="${classes.live}" aria-live="polite" aria-atomic="true"></div>
147
+ </div>`;
148
+ const query = (selector) => root.querySelector(`.${selector}`);
149
+ return {
150
+ root,
151
+ backdrop: query(classes.backdrop),
152
+ container: query(classes.container),
153
+ closeButton: query(classes.close),
154
+ input: query(classes.input),
155
+ results: query(classes.results),
156
+ footer: query(classes.footer),
157
+ live: query(classes.live),
158
+ };
159
+ }
160
+
161
+ // mirrors web-commandpalette src/render.ts itemHtml() (a link; the code takes the icon slot)
162
+ function renderItemHtml(chapter, index, isActive, needle) {
163
+ const classes = SPOTLIGHT_CLASSES;
164
+ return (
165
+ `<a class="${classes.item}${isActive ? ` ${classes.itemActive}` : ''}"` +
166
+ ` id="${getItemId(index)}" data-index="${index}" role="option" aria-selected="${isActive}" href="${mapToEscapedHtml(chapter.href)}">` +
167
+ (chapter.code ? `<span class="${classes.itemCode}">${renderHighlightedText(chapter.code, needle)}</span>` : '') +
168
+ `<span class="${classes.itemContent}">` +
169
+ `<span class="${classes.itemTitle}">${renderHighlightedText(chapter.title, needle)}</span>` +
170
+ `</span>` +
171
+ `</a>`
172
+ );
173
+ }
174
+
175
+ /** A heading + rows. */
176
+ // mirrors web-commandpalette src/render.ts sectionHtml()
177
+ function renderSectionHtml(heading, rowsHtml) {
178
+ const classes = SPOTLIGHT_CLASSES;
179
+ return (
180
+ `<div class="${classes.homeSection}" role="group"${heading ? ` aria-label="${mapToEscapedHtml(heading)}"` : ''}>` +
181
+ (heading ? `<div class="${classes.homeHeading}"><span aria-hidden="true">${mapToEscapedHtml(heading)}</span></div>` : '') +
182
+ `${rowsHtml}</div>`
183
+ );
184
+ }
185
+
186
+ /** Rows, with a heading section per run of the same page. */
187
+ // mirrors web-commandpalette src/render.ts rowsHtml()
188
+ function renderRowsHtml(chapters, activeIndex, needle, getGroupHeading) {
189
+ let html = '';
190
+ let group;
191
+ let rowsHtml = '';
192
+ const flush = () => {
193
+ html += group ? renderSectionHtml(getGroupHeading(group), rowsHtml) : rowsHtml;
194
+ rowsHtml = '';
195
+ };
196
+ chapters.forEach((chapter, index) => {
197
+ if (chapter.pageHref !== group) {
198
+ flush();
199
+ group = chapter.pageHref;
200
+ }
201
+ rowsHtml += renderItemHtml(chapter, index, index === activeIndex, needle);
202
+ });
203
+ flush();
204
+ return html;
205
+ }
206
+
207
+ // mirrors web-commandpalette src/render.ts footerHtml() hint()
208
+ function renderFooterHtml() {
209
+ const classes = SPOTLIGHT_CLASSES;
210
+ const renderHint = (key, text) =>
211
+ `<div class="${classes.hint}"><span class="${classes.key}">${mapToEscapedHtml(key)}</span><span>${mapToEscapedHtml(text)}</span></div>`;
212
+ return [
213
+ renderHint('↑↓', LABELS.navigate),
214
+ renderHint('↵', LABELS.jump),
215
+ renderHint(checkIsMac() ? '⌘↵' : 'Ctrl ↵', LABELS.newTab),
216
+ renderHint('Esc', LABELS.close),
217
+ ].join('');
218
+ }
219
+
220
+ // ── chapter data (spotlight only) ────────────────────────────────────────────
221
+
222
+ let chapterIndexPromise = null;
223
+ /** The site-wide index from the Vite plugin; empty when served without it. */
224
+ function loadChapterIndex() {
225
+ // A literal id: Vite resolves it to the plugin's virtual module (vite/index.js).
226
+ chapterIndexPromise ??= import('virtual:kmwc-chapters').then(
227
+ (chapterIndexModule) => chapterIndexModule.default || [],
228
+ () => []
229
+ );
230
+ return chapterIndexPromise;
231
+ }
232
+
233
+ function getCurrentPageHref() {
234
+ return decodeURIComponent(location.pathname.split('/').pop() || 'index.html');
235
+ }
236
+
237
+ const checkIsCurrentPage = (pageHref, currentPageHref) => pageHref === currentPageHref || pageHref.replace(/\.html$/, '') === currentPageHref;
238
+
239
+ /** The current page's chapters, read from the DOM (same shape as the index entries). */
240
+ function getLivePageChapters(currentPageHref) {
241
+ const pageTitle = document.querySelector('h1')?.textContent.trim() || document.title;
242
+ return ensureSectionAnchors().map((section) => {
243
+ const code = getChapterCode(section.label);
244
+ return {
245
+ code,
246
+ title: code ? section.label.replace(CHAPTER_CODE_PATTERN, '') : section.label,
247
+ label: section.label,
248
+ pageHref: currentPageHref,
249
+ pageTitle,
250
+ href: `${currentPageHref}#${code ? code.toLowerCase() : section.id}`,
251
+ };
252
+ });
253
+ }
254
+
255
+ /** This page first (live), then every other page from the index. */
256
+ function mapToSearchedChapters(indexedChapters) {
257
+ const currentPageHref = getCurrentPageHref();
258
+ const otherPageChapters = indexedChapters.filter((chapter) => !checkIsCurrentPage(chapter.pageHref, currentPageHref));
259
+ return [...getLivePageChapters(currentPageHref), ...otherPageChapters];
260
+ }
261
+
262
+ // ── the spotlight ────────────────────────────────────────────────────────────
263
+
264
+ let mountedSpotlight = null;
265
+
266
+ /**
267
+ * Mount (or remount) the spotlight. Returns null when the site turned it off.
268
+ * @returns {{ open(): void, close(): void, toggle(): void, unmount(): void } | null}
269
+ */
270
+ export function mountSpotlight() {
271
+ mountedSpotlight?.unmount();
272
+ mountedSpotlight = null;
273
+ if (!getPageConfig().isSpotlightEnabled) return null;
274
+
275
+ const classes = SPOTLIGHT_CLASSES;
276
+ const shell = createShell();
277
+ const { root, backdrop, container, closeButton, input, results, footer, live } = shell;
278
+ footer.innerHTML = renderFooterHtml();
279
+
280
+ const shortcutKeycap = getChordKeycap(SPOTLIGHT_OPEN_KEY);
281
+ const triggerButton = document.createElement('button');
282
+ triggerButton.type = 'button';
283
+ triggerButton.className = classes.trigger;
284
+ triggerButton.setAttribute('aria-label', `${LABELS.dialogLabel} (${shortcutKeycap})`);
285
+ triggerButton.title = `${LABELS.dialogLabel} (${shortcutKeycap})`;
286
+ triggerButton.innerHTML = `${SEARCH_ICON_HTML}<kbd class="${classes.triggerKey}">${shortcutKeycap}</kbd>`;
287
+
288
+ document.body.append(triggerButton, root);
289
+ const fullscreenQuery = window.matchMedia?.(SPOTLIGHT_FULLSCREEN_MEDIA);
290
+
291
+ // ── state ────────────────────────────────────────────────────────────────
292
+ let isOpen = false;
293
+ let searchedChapters = [];
294
+ let shownChapters = [];
295
+ let activeIndex = -1;
296
+ let returnFocusElement = null;
297
+ let releaseScroll = null;
298
+
299
+ // ── render ───────────────────────────────────────────────────────────────
300
+ function renderResults() {
301
+ const needle = input.value;
302
+ const currentPageHref = getCurrentPageHref();
303
+ shownChapters = searchChapters(searchedChapters, needle);
304
+ activeIndex = shownChapters.length ? 0 : -1;
305
+ const getGroupHeading = (pageHref) => {
306
+ const pageTitle = shownChapters.find((chapter) => chapter.pageHref === pageHref)?.pageTitle ?? pageHref;
307
+ return checkIsCurrentPage(pageHref, currentPageHref) ? fillLabel(LABELS.thisPage, { page: pageTitle }) : pageTitle;
308
+ };
309
+ if (shownChapters.length) results.innerHTML = renderRowsHtml(shownChapters, activeIndex, needle, getGroupHeading);
310
+ else {
311
+ const emptyText = searchedChapters.length ? fillLabel(LABELS.noResults, { query: needle.trim() }) : LABELS.noChapters;
312
+ results.innerHTML = `<div class="${classes.empty}">${mapToEscapedHtml(emptyText)}</div>`;
313
+ }
314
+ live.textContent = fillLabel(LABELS.resultsCount, { count: shownChapters.length });
315
+ syncActiveItem();
316
+ }
317
+
318
+ // mirrors web-commandpalette src/render.ts Renderer #paintActive() + #syncActiveDescendant() + #scrollActive()
319
+ function syncActiveItem() {
320
+ const previousItem = results.querySelector(`.${classes.itemActive}`);
321
+ const nextItem = activeIndex >= 0 ? results.querySelector(`#${getItemId(activeIndex)}`) : null;
322
+ if (previousItem !== nextItem) {
323
+ if (previousItem) {
324
+ previousItem.classList.remove(classes.itemActive);
325
+ previousItem.setAttribute('aria-selected', 'false');
326
+ }
327
+ if (nextItem) {
328
+ nextItem.classList.add(classes.itemActive);
329
+ nextItem.setAttribute('aria-selected', 'true');
330
+ }
331
+ }
332
+ if (activeIndex >= 0) input.setAttribute('aria-activedescendant', getItemId(activeIndex));
333
+ else input.removeAttribute('aria-activedescendant');
334
+ nextItem?.scrollIntoView?.({ block: 'nearest' });
335
+ }
336
+
337
+ function setActive(index) {
338
+ if (index < 0 || index >= shownChapters.length || index === activeIndex) return;
339
+ activeIndex = index;
340
+ syncActiveItem();
341
+ }
342
+
343
+ // ── commit (spotlight only: rows are links) ──────────────────────────────
344
+ function openChapter(chapter, isNewTab) {
345
+ if (!chapter) return;
346
+ if (isNewTab) {
347
+ window.open(chapter.href, '_blank', 'noopener');
348
+ return;
349
+ }
350
+ close();
351
+ if (!checkIsCurrentPage(chapter.pageHref, getCurrentPageHref())) {
352
+ location.href = chapter.href;
353
+ return;
354
+ }
355
+ const hash = chapter.href.slice(chapter.href.indexOf('#'));
356
+ // Same hash again fires no hashchange — scroll ourselves.
357
+ if (location.hash === hash) scrollToChapterHash(hash);
358
+ else location.hash = hash;
359
+ }
360
+
361
+ // ── open / close ─────────────────────────────────────────────────────────
362
+ // mirrors web-commandpalette src/web-component.ts open()
363
+ function open() {
364
+ if (isOpen) return;
365
+ isOpen = true;
366
+ returnFocusElement = getDeepActiveElement();
367
+ input.value = '';
368
+ root.classList.toggle(classes.rootFullscreen, !!fullscreenQuery?.matches);
369
+ root.classList.add(classes.rootActive);
370
+ releaseScroll = lockBodyScroll();
371
+ loadChapterIndex().then((indexedChapters) => {
372
+ searchedChapters = mapToSearchedChapters(indexedChapters);
373
+ if (isOpen) renderResults();
374
+ });
375
+ input.focus();
376
+ }
377
+
378
+ // mirrors web-commandpalette src/web-component.ts close()
379
+ function close() {
380
+ if (!isOpen) return;
381
+ isOpen = false;
382
+ root.classList.remove(classes.rootActive);
383
+ releaseScroll?.();
384
+ releaseScroll = null;
385
+ const backElement = returnFocusElement;
386
+ returnFocusElement = null;
387
+ // Blur first: when focus came from <body> (not focusable), it would otherwise stay in the hidden input.
388
+ input.blur();
389
+ if (backElement?.isConnected && typeof backElement.focus === 'function') backElement.focus();
390
+ }
391
+
392
+ // mirrors web-commandpalette src/web-component.ts toggle()
393
+ function toggle() {
394
+ if (isOpen) close();
395
+ else open();
396
+ }
397
+
398
+ // ── events ───────────────────────────────────────────────────────────────
399
+ triggerButton.addEventListener('click', open);
400
+ input.addEventListener('input', renderResults);
401
+
402
+ // mirrors web-commandpalette src/keyboard.ts handleInputKeydown() (no wizard / paging / favorites keys)
403
+ input.addEventListener('keydown', (event) => {
404
+ if (event.isComposing) return;
405
+ const direction = KEY_DIRECTIONS[event.key];
406
+ if (direction) {
407
+ event.preventDefault();
408
+ setActive(getMovedIndex(activeIndex, shownChapters.length, direction));
409
+ } else if (event.key === 'Enter') {
410
+ event.preventDefault();
411
+ openChapter(shownChapters[activeIndex], event.ctrlKey || event.metaKey);
412
+ } else if (event.key === 'Tab') {
413
+ event.preventDefault(); // wcp: Tab selects one wizard step — a jump list has none; keep focus here
414
+ } else if (event.key === 'Escape') {
415
+ event.preventDefault();
416
+ close();
417
+ }
418
+ });
419
+
420
+ results.addEventListener('pointermove', (event) => {
421
+ const itemElement = event.target.closest?.('[data-index]');
422
+ if (itemElement) setActive(Number(itemElement.dataset.index));
423
+ });
424
+
425
+ results.addEventListener('click', (event) => {
426
+ const itemElement = event.target.closest?.('[data-index]');
427
+ // Modified clicks (new tab / window) are the browser's.
428
+ if (!itemElement || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
429
+ event.preventDefault();
430
+ openChapter(shownChapters[Number(itemElement.dataset.index)], false);
431
+ });
432
+
433
+ backdrop.addEventListener('click', close);
434
+ closeButton.addEventListener('click', close);
435
+
436
+ // Focus trap: from anywhere else in the dialog (the fullscreen close button) Tab returns to the input.
437
+ // mirrors web-commandpalette src/web-component.ts container keydown (no feedback line)
438
+ container.addEventListener('keydown', (event) => {
439
+ if (event.key === 'Tab' && event.target !== input) {
440
+ event.preventDefault();
441
+ input.focus();
442
+ } else if (event.key === 'Escape' && event.target !== input) {
443
+ event.preventDefault();
444
+ close();
445
+ }
446
+ });
447
+
448
+ // mirrors web-commandpalette src/keyboard.ts GlobalKeys.handle() (Ctrl/⌘+openKey only — no `g <letter>` sequences)
449
+ const handleDocumentKeydown = (event) => {
450
+ if (event.defaultPrevented) return;
451
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === SPOTLIGHT_OPEN_KEY) {
452
+ event.preventDefault();
453
+ toggle();
454
+ }
455
+ };
456
+ document.addEventListener('keydown', handleDocumentKeydown);
457
+
458
+ // Warm the index once the page is idle, so the first open is instant.
459
+ (window.requestIdleCallback || setTimeout)(() => loadChapterIndex());
460
+
461
+ mountedSpotlight = {
462
+ open,
463
+ close,
464
+ toggle,
465
+ unmount() {
466
+ document.removeEventListener('keydown', handleDocumentKeydown);
467
+ close();
468
+ triggerButton.remove();
469
+ root.remove();
470
+ },
471
+ };
472
+ return mountedSpotlight;
473
+ }