@keenmate/web-components-devkit 1.0.0-rc02 → 1.0.0-rc03
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 +64 -0
- package/README.md +53 -12
- package/package.json +2 -1
- package/src/cli/commands/doctor.js +1 -0
- package/src/config.d.ts +3 -0
- package/src/config.js +1 -0
- package/src/examples/chapter-links.js +35 -0
- package/src/examples/chapter-search.js +84 -0
- package/src/examples/constants.js +51 -0
- package/src/examples/css/content.css +4 -34
- package/src/examples/css/controls.css +26 -21
- package/src/examples/css/copy-link.css +1 -0
- package/src/examples/css/demo.css +2 -0
- package/src/examples/css/form-controls.css +285 -0
- package/src/examples/css/spotlight.css +417 -0
- package/src/examples/css/style.css +3 -0
- package/src/examples/css/tables.css +141 -0
- package/src/examples/css/tokens.css +28 -0
- package/src/examples/index.d.ts +38 -0
- package/src/examples/index.js +6 -3
- package/src/examples/kit.js +11 -4
- package/src/examples/page-config.js +1 -0
- package/src/examples/sections.js +38 -1
- package/src/examples/spotlight.js +473 -0
- package/src/vite/chapter-index.js +107 -0
- package/src/vite/index.js +19 -1
- package/templates/claude-block.md +9 -2
- package/templates/new/src/css/base.css +5 -3
- package/templates/new-minimal/src/css/base.css +5 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site-wide chapter index behind the spotlight: every <h2> of every
|
|
3
|
+
* example page, read at build time (and on demand in dev) from the COMPOSED
|
|
4
|
+
* page — includes resolved, so a heading from a partial counts too.
|
|
5
|
+
*
|
|
6
|
+
* Served as the virtual module `virtual:kmwc-chapters` (see index.js), which
|
|
7
|
+
* the spotlight imports lazily on first open.
|
|
8
|
+
*
|
|
9
|
+
* Each entry links by section code when the heading has one (`#th07` — the
|
|
10
|
+
* kit resolves it on arrival, see chapter-links.js), else by the slug of its
|
|
11
|
+
* heading text, the same slug the chapter nav assigns. Pure string work, like
|
|
12
|
+
* page-composer.js.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { basename } from 'node:path';
|
|
16
|
+
import { composePage } from './page-composer.js';
|
|
17
|
+
import { generateSlug, getChapterCode } from '../examples/sections.js';
|
|
18
|
+
import { CHAPTER_CODE_PATTERN } from '../examples/constants.js';
|
|
19
|
+
|
|
20
|
+
/** Pages that are not part of the chapter index (the landing page has cards, not chapters). */
|
|
21
|
+
export const UNINDEXED_PAGE_NAMES = ['index'];
|
|
22
|
+
|
|
23
|
+
const COMMENT_SCRIPT_OR_STYLE_PATTERN = /<!--[\s\S]*?-->|<script\b[^>]*>[\s\S]*?<\/script>|<style\b[^>]*>[\s\S]*?<\/style>/gi;
|
|
24
|
+
const HEADING_PATTERN = /<h2\b[^>]*>([\s\S]*?)<\/h2>/gi;
|
|
25
|
+
const PAGE_HEADING_PATTERN = /<h1\b[^>]*>([\s\S]*?)<\/h1>/i;
|
|
26
|
+
const DOCUMENT_TITLE_PATTERN = /<title\b[^>]*>([\s\S]*?)<\/title>/i;
|
|
27
|
+
// The chips sections.js strips from a label (HEADING_CHIP_SELECTOR: `.badge`).
|
|
28
|
+
const BADGE_PATTERN = /<span\b[^>]*\bclass="[^"]*\bbadge\b[^"]*"[^>]*>[\s\S]*?<\/span>/gi;
|
|
29
|
+
const TAG_PATTERN = /<[^>]+>/g;
|
|
30
|
+
|
|
31
|
+
const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', middot: '·', mdash: '—', ndash: '–', hellip: '…', rarr: '→', larr: '←' };
|
|
32
|
+
|
|
33
|
+
export function mapToDecodedText(html) {
|
|
34
|
+
return html.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
|
|
35
|
+
if (name[0] === '#') {
|
|
36
|
+
const codePoint = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
|
|
37
|
+
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
38
|
+
}
|
|
39
|
+
return NAMED_ENTITIES[name.toLowerCase()] ?? entity;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Heading HTML → the label the kit shows (badges dropped, tags stripped, entities decoded). */
|
|
44
|
+
export function getHeadingText(headingHtml) {
|
|
45
|
+
const text = mapToDecodedText(headingHtml.replace(BADGE_PATTERN, '').replace(TAG_PATTERN, ''));
|
|
46
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Chapters of one composed page.
|
|
51
|
+
* @param {string} html the composed page
|
|
52
|
+
* @param {{ pageHref: string }} pageInfo
|
|
53
|
+
*/
|
|
54
|
+
export function getPageChapters(html, { pageHref }) {
|
|
55
|
+
const visibleHtml = html.replace(COMMENT_SCRIPT_OR_STYLE_PATTERN, '');
|
|
56
|
+
// The <h1> (the layout's page title); <title> carries the site name after " — ".
|
|
57
|
+
const headingTitleMatch = visibleHtml.match(PAGE_HEADING_PATTERN);
|
|
58
|
+
const documentTitleMatch = visibleHtml.match(DOCUMENT_TITLE_PATTERN);
|
|
59
|
+
const pageTitle =
|
|
60
|
+
(headingTitleMatch && getHeadingText(headingTitleMatch[1])) ||
|
|
61
|
+
(documentTitleMatch && getHeadingText(documentTitleMatch[1]).split(' — ')[0]) ||
|
|
62
|
+
pageHref;
|
|
63
|
+
|
|
64
|
+
const usedAnchors = new Set();
|
|
65
|
+
const chapters = [];
|
|
66
|
+
for (const headingMatch of visibleHtml.matchAll(HEADING_PATTERN)) {
|
|
67
|
+
const label = getHeadingText(headingMatch[1]);
|
|
68
|
+
if (!label) continue;
|
|
69
|
+
const code = getChapterCode(label);
|
|
70
|
+
let anchor = code ? code.toLowerCase() : generateSlug(label) || `section-${chapters.length + 1}`;
|
|
71
|
+
if (usedAnchors.has(anchor)) {
|
|
72
|
+
let suffix = 2;
|
|
73
|
+
while (usedAnchors.has(`${anchor}-${suffix}`)) suffix++;
|
|
74
|
+
anchor = `${anchor}-${suffix}`;
|
|
75
|
+
}
|
|
76
|
+
usedAnchors.add(anchor);
|
|
77
|
+
chapters.push({
|
|
78
|
+
code,
|
|
79
|
+
title: code ? label.replace(CHAPTER_CODE_PATTERN, '') : label,
|
|
80
|
+
label,
|
|
81
|
+
pageHref,
|
|
82
|
+
pageTitle,
|
|
83
|
+
href: `${pageHref}#${anchor}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return chapters;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Chapters of every example page, in page order (alphabetical, as Vite builds them).
|
|
91
|
+
* @param {import('../config.d.ts').KmwcConfig} config
|
|
92
|
+
* @param {Record<string, string>} pages page name → absolute path (getExamplePages)
|
|
93
|
+
*/
|
|
94
|
+
export function generateChapterIndex(config, pages, readFile = (path) => readFileSync(path, 'utf8')) {
|
|
95
|
+
return Object.entries(pages)
|
|
96
|
+
.filter(([pageName]) => !UNINDEXED_PAGE_NAMES.includes(pageName))
|
|
97
|
+
.sort(([firstName], [secondName]) => firstName.localeCompare(secondName))
|
|
98
|
+
.flatMap(([, pagePath]) => {
|
|
99
|
+
const pageHref = basename(pagePath);
|
|
100
|
+
try {
|
|
101
|
+
const composedHtml = composePage(readFile(pagePath), { config, pagePath: `/${pageHref}` });
|
|
102
|
+
return getPageChapters(composedHtml, { pageHref });
|
|
103
|
+
} catch {
|
|
104
|
+
return []; // a broken page reports its own error when served — never break the index
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
package/src/vite/index.js
CHANGED
|
@@ -21,9 +21,13 @@ import { join, resolve } from 'node:path';
|
|
|
21
21
|
import { getKmwcConfig, getDevkitPackageJson, generateBuildDefines, loadPeerModule, DEVKIT_ROOT, NON_PAGE_HTML_FILES } from '../config.js';
|
|
22
22
|
import { renderThemeBootstrapScript } from '../examples/theme-switch.js';
|
|
23
23
|
import { composePage } from './page-composer.js';
|
|
24
|
+
import { generateChapterIndex } from './chapter-index.js';
|
|
24
25
|
|
|
25
26
|
const AUTO_ENTRY_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'auto.js');
|
|
26
27
|
const VIRTUAL_AUTO_ID = 'virtual:kmwc-examples';
|
|
28
|
+
// The spotlight's chapter index (chapter-index.js); spotlight.js imports it by this literal id.
|
|
29
|
+
const VIRTUAL_CHAPTERS_ID = 'virtual:kmwc-chapters';
|
|
30
|
+
const RESOLVED_CHAPTERS_ID = '\0' + VIRTUAL_CHAPTERS_ID;
|
|
27
31
|
const KIT_STYLESHEET_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'css', 'style.css').replace(/\\/g, '/');
|
|
28
32
|
// Vite's dev-server URL for a file outside the project root.
|
|
29
33
|
const KIT_STYLESHEET_DEV_URL = `/@fs/${KIT_STYLESHEET_PATH.replace(/^\//, '')}`;
|
|
@@ -79,13 +83,26 @@ export function createExamplesPlugin(pluginConfig = {}) {
|
|
|
79
83
|
},
|
|
80
84
|
|
|
81
85
|
resolveId(moduleId) {
|
|
82
|
-
|
|
86
|
+
if (moduleId === VIRTUAL_AUTO_ID) return AUTO_ENTRY_PATH;
|
|
87
|
+
if (moduleId === VIRTUAL_CHAPTERS_ID) return RESOLVED_CHAPTERS_ID;
|
|
88
|
+
return null;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
load(moduleId) {
|
|
92
|
+
if (moduleId !== RESOLVED_CHAPTERS_ID) return null;
|
|
93
|
+
const chapters = config.examples.isSpotlightEnabled ? generateChapterIndex(config, getExamplePages(config)) : [];
|
|
94
|
+
return `export default ${JSON.stringify(chapters)};`;
|
|
83
95
|
},
|
|
84
96
|
|
|
85
97
|
// A partial isn't a module, so Vite doesn't know which pages use it —
|
|
86
98
|
// reload the page on any non-root HTML change (partials/…).
|
|
87
99
|
handleHotUpdate({ file, server }) {
|
|
88
100
|
const relativePath = file.replace(/\\/g, '/').replace(config.root.replace(/\\/g, '/') + '/', '');
|
|
101
|
+
// Any page or partial may change a heading — rebuild the chapter index on its next import.
|
|
102
|
+
if (relativePath.endsWith('.html')) {
|
|
103
|
+
const chaptersModule = server.moduleGraph.getModuleById(RESOLVED_CHAPTERS_ID);
|
|
104
|
+
if (chaptersModule) server.moduleGraph.invalidateModule(chaptersModule);
|
|
105
|
+
}
|
|
89
106
|
if (relativePath.endsWith('.html') && relativePath.includes('/') && !relativePath.startsWith('test/')) {
|
|
90
107
|
server.ws.send({ type: 'full-reload' });
|
|
91
108
|
return [];
|
|
@@ -118,6 +135,7 @@ export function createExamplesPlugin(pluginConfig = {}) {
|
|
|
118
135
|
mode: isBuild ? 'build' : 'dev',
|
|
119
136
|
canonicalOrigin: config.examples.canonicalOrigin,
|
|
120
137
|
storagePrefix: config.examples.storagePrefix,
|
|
138
|
+
isSpotlightEnabled: config.examples.isSpotlightEnabled,
|
|
121
139
|
repository: config.repository,
|
|
122
140
|
homepage: config.homepage,
|
|
123
141
|
devkitVersion: devkitPackageJson.version,
|
|
@@ -21,12 +21,19 @@ locally — every local fork is how they drifted apart.
|
|
|
21
21
|
- **Example page styles:** shared rules are in `@layer kmwc` and read
|
|
22
22
|
`--base-*` tokens (pages support light / dark / auto via the theme switch), so
|
|
23
23
|
any unlayered rule wins. Put component-only rules in `examples-local.css`.
|
|
24
|
-
Re-theme with `kmwc.theme.accentColor`, never by copying CSS.
|
|
25
|
-
|
|
24
|
+
Re-theme with `kmwc.theme.accentColor`, never by copying CSS. Native form
|
|
25
|
+
controls (inputs, selects, checkboxes, radios, `role="switch"`, buttons) and
|
|
26
|
+
tables are already styled by the kit (`.reference-table`, `.table-striped`,
|
|
27
|
+
`.table-compact`, …) — don't restyle them per page; `kmwc-native` opts an
|
|
28
|
+
element out. If a second component needs a pattern, it belongs in the devkit.
|
|
26
29
|
- **Example pages are fragments:** no `<html>`/`<head>`/`<header>` — start with
|
|
27
30
|
`<meta name="kmwc:title" content="…">`, the devkit layout adds the rest. Shared text
|
|
28
31
|
goes in `partials/` via `<kmwc-include>`; show code with `<kmwc-demo>` (the live
|
|
29
32
|
markup IS the snippet) — never hand-copy a demo into a `.code-block`.
|
|
33
|
+
- **Every example has a permanent code:** each `<h2>` starts `TH07 · Title`
|
|
34
|
+
(page prefix + number; a plain `Summary` is exempt). New examples take the
|
|
35
|
+
next free number; never renumber or reuse one — the rules are the rulebook's
|
|
36
|
+
`example-pages.md`.
|
|
30
37
|
- **Controls persistence:**
|
|
31
38
|
`import { attachControlsPersistence } from '@keenmate/web-components-devkit/examples'`.
|
|
32
39
|
**Landing page:** `renderExamplesIndex({ pages })` from the same module.
|
|
@@ -36,8 +36,10 @@
|
|
|
36
36
|
display: none;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
/* Scoped to :host — the whole file also reaches the page through index.ts's
|
|
40
|
+
CSS import, and a bare `*` would reset box-sizing on the host page too. */
|
|
41
|
+
:host *,
|
|
42
|
+
:host *::before,
|
|
43
|
+
:host *::after {
|
|
42
44
|
box-sizing: inherit;
|
|
43
45
|
}
|