@keenmate/web-components-devkit 0.0.0-stage → 1.0.0-rc02

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 (109) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/LICENSE +21 -0
  3. package/README.md +434 -2
  4. package/components.json +206 -0
  5. package/package.json +77 -4
  6. package/src/cli/args.js +16 -0
  7. package/src/cli/commands/check-release.js +66 -0
  8. package/src/cli/commands/check-vars.js +116 -0
  9. package/src/cli/commands/doctor.js +289 -0
  10. package/src/cli/commands/new.js +323 -0
  11. package/src/cli/commands/readme.js +111 -0
  12. package/src/cli/commands/sync.js +48 -0
  13. package/src/cli/kmwc.js +73 -0
  14. package/src/cli/sync-plan.js +163 -0
  15. package/src/config.d.ts +149 -0
  16. package/src/config.js +279 -0
  17. package/src/examples/auto.js +20 -0
  18. package/src/examples/chapter-nav.js +246 -0
  19. package/src/examples/constants.js +80 -0
  20. package/src/examples/controls-persistence.js +97 -0
  21. package/src/examples/copy-link.js +89 -0
  22. package/src/examples/css/base.css +215 -0
  23. package/src/examples/css/chapter-nav.css +179 -0
  24. package/src/examples/css/content.css +251 -0
  25. package/src/examples/css/controls.css +205 -0
  26. package/src/examples/css/copy-link.css +82 -0
  27. package/src/examples/css/demo.css +50 -0
  28. package/src/examples/css/index-page.css +118 -0
  29. package/src/examples/css/style.css +30 -0
  30. package/src/examples/css/theme-switch.css +38 -0
  31. package/src/examples/css/tokens.css +124 -0
  32. package/src/examples/css/widgets.css +133 -0
  33. package/src/examples/demo-code.js +30 -0
  34. package/src/examples/index-page.js +78 -0
  35. package/src/examples/index.d.ts +107 -0
  36. package/src/examples/index.js +18 -0
  37. package/src/examples/kit.js +22 -0
  38. package/src/examples/page-config.js +30 -0
  39. package/src/examples/sections.js +53 -0
  40. package/src/examples/theme-switch.js +72 -0
  41. package/src/examples/version-badge.js +23 -0
  42. package/src/playwright/index.d.ts +15 -0
  43. package/src/playwright/index.js +66 -0
  44. package/src/template.js +11 -0
  45. package/src/vite/index.d.ts +28 -0
  46. package/src/vite/index.js +243 -0
  47. package/src/vite/page-composer.js +216 -0
  48. package/templates/Dockerfile.tmpl +39 -0
  49. package/templates/Makefile.tmpl +136 -0
  50. package/templates/claude-block.md +40 -0
  51. package/templates/dockerignore.tmpl +10 -0
  52. package/templates/examples-layout.html +13 -0
  53. package/templates/new/.claude/commands/publish.md +226 -0
  54. package/templates/new/CHANGELOG.md +13 -0
  55. package/templates/new/LICENSE +21 -0
  56. package/templates/new/README.md +71 -0
  57. package/templates/new/_gitignore +7 -0
  58. package/templates/new/component-variables.manifest.json +32 -0
  59. package/templates/new/custom-elements-manifest.config.mjs +23 -0
  60. package/templates/new/docs/accessibility.md +24 -0
  61. package/templates/new/docs/examples.md +33 -0
  62. package/templates/new/docs/theming.md +54 -0
  63. package/templates/new/docs/usage.md +51 -0
  64. package/templates/new/e2e/dark-mode.spec.ts +30 -0
  65. package/templates/new/e2e/smoke.spec.ts +26 -0
  66. package/templates/new/examples-basic.html +57 -0
  67. package/templates/new/examples-theming.html +28 -0
  68. package/templates/new/index.html +16 -0
  69. package/templates/new/partials/note.html +5 -0
  70. package/templates/new/playwright.config.ts +4 -0
  71. package/templates/new/src/__FEATURE__.ts +55 -0
  72. package/templates/new/src/css/animations.css +1 -0
  73. package/templates/new/src/css/base.css +43 -0
  74. package/templates/new/src/css/controls.css +20 -0
  75. package/templates/new/src/css/dark-mode.css +36 -0
  76. package/templates/new/src/css/floating.css +1 -0
  77. package/templates/new/src/css/main.css +27 -0
  78. package/templates/new/src/css/states.css +22 -0
  79. package/templates/new/src/css/variables.css +42 -0
  80. package/templates/new/src/env.d.ts +7 -0
  81. package/templates/new/src/index.ts +17 -0
  82. package/templates/new/src/logger.ts +11 -0
  83. package/templates/new/src/types.ts +25 -0
  84. package/templates/new/src/web-component.ts +73 -0
  85. package/templates/new/test/dark-mode.html +18 -0
  86. package/templates/new/test/fixture.html +11 -0
  87. package/templates/new/tsconfig.json +15 -0
  88. package/templates/new/vite.config.ts +5 -0
  89. package/templates/new-minimal/CHANGELOG.md +13 -0
  90. package/templates/new-minimal/README.md +71 -0
  91. package/templates/new-minimal/component-variables.manifest.json +24 -0
  92. package/templates/new-minimal/docs/accessibility.md +20 -0
  93. package/templates/new-minimal/docs/examples.md +17 -0
  94. package/templates/new-minimal/docs/theming.md +53 -0
  95. package/templates/new-minimal/docs/usage.md +43 -0
  96. package/templates/new-minimal/e2e/smoke.spec.ts +10 -0
  97. package/templates/new-minimal/examples-basic.html +23 -0
  98. package/templates/new-minimal/examples-theming.html +28 -0
  99. package/templates/new-minimal/src/__FEATURE__.ts +37 -0
  100. package/templates/new-minimal/src/css/base.css +48 -0
  101. package/templates/new-minimal/src/css/controls.css +1 -0
  102. package/templates/new-minimal/src/css/states.css +1 -0
  103. package/templates/new-minimal/src/css/variables.css +28 -0
  104. package/templates/new-minimal/src/index.ts +17 -0
  105. package/templates/new-minimal/src/types.ts +10 -0
  106. package/templates/new-minimal/src/web-component.ts +48 -0
  107. package/templates/new-minimal/test/dark-mode.html +18 -0
  108. package/templates/new-minimal/test/fixture.html +11 -0
  109. package/templates/nginx.conf.tmpl +43 -0
package/src/config.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `@keenmate/web-components-devkit` — config resolution.
3
+ *
4
+ * Every component describes itself ONCE, in the `kmwc` section of its
5
+ * package.json. The Vite preset, the Playwright preset and every `kmwc` CLI
6
+ * command read it through `getKmwcConfig()`, so ports, titles, accent colours,
7
+ * analytics ids and image names can't drift between vite.config.ts, the
8
+ * Makefile, the Dockerfile and the example pages.
9
+ *
10
+ * Plain JSON (not a JS config file) on purpose: the CLI, templates and the
11
+ * README generator read it without a TS loader, and the registry generator can
12
+ * read sibling repos without executing their code.
13
+ */
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { join, resolve, dirname } from 'node:path';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
+ import { createRequire } from 'node:module';
18
+
19
+ /** Absolute path of the devkit package root (this repo). */
20
+ export const DEVKIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
21
+
22
+ const RULEBOOK_RELATIVE_PATH = join('BlissFramework', 'guidelines', 'web-components');
23
+
24
+ /**
25
+ * The nearest BlissFramework rulebook checkout: `<ancestor>/BlissFramework/
26
+ * guidelines/web-components` for the first ancestor of `startPath` that has one
27
+ * (e.g. C:\Git\KM\web-editor → C:\Git\BlissFramework\…), or null. Searching
28
+ * from the component (not a fixed path from the devkit) is what keeps it
29
+ * working when the devkit is installed into node_modules from npm.
30
+ */
31
+ export function getRulebookPath(startPath) {
32
+ let directory = resolve(startPath);
33
+ for (;;) {
34
+ const candidatePath = join(directory, RULEBOOK_RELATIVE_PATH);
35
+ if (existsSync(candidatePath)) return candidatePath;
36
+ const parentDirectory = dirname(directory);
37
+ if (parentDirectory === directory) return null;
38
+ directory = parentDirectory;
39
+ }
40
+ }
41
+
42
+ /** Fallback rulebook location: the nearest checkout above the devkit, else next to the KM folder. */
43
+ export const DEFAULT_RULEBOOK_PATH = getRulebookPath(DEVKIT_ROOT) ?? resolve(DEVKIT_ROOT, '..', '..', RULEBOOK_RELATIVE_PATH);
44
+
45
+ /** Files the devkit now owns — finding one of these in a component is a doctor error. */
46
+ export const LEGACY_FILES = [
47
+ 'examples-chapter-nav.js',
48
+ 'examples-copy-link.js',
49
+ 'examples-controls-store.js',
50
+ 'examples-shared.css',
51
+ 'plausible-snippet.html',
52
+ 'scripts/check-variable-manifest.mjs',
53
+ ];
54
+
55
+ /**
56
+ * Port blocks: every repo owns one block of 100 (components.json `port`):
57
+ * dev server = block, preview = +1, examples image = +10. Each registry kind
58
+ * has its own range with headroom, numbered by 100 in registry order, so a new
59
+ * component lands next to the other components.
60
+ */
61
+ export const PORT_BLOCK_SIZE = 100;
62
+ export const PREVIEW_PORT_OFFSET = 1;
63
+ export const IMAGE_PORT_OFFSET = 10;
64
+ export const PORT_RANGES = [
65
+ { kinds: ['component'], firstBlock: 12100, lastBlock: 14900 },
66
+ { kinds: ['library'], firstBlock: 15000, lastBlock: 15900 },
67
+ { kinds: ['showcase', 'docs'], firstBlock: 16000, lastBlock: 16900 },
68
+ { kinds: ['test'], firstBlock: 17000, lastBlock: 17900 },
69
+ ];
70
+
71
+ export function getPortsForBlock(portBlock) {
72
+ return { dev: portBlock, preview: portBlock + PREVIEW_PORT_OFFSET, image: portBlock + IMAGE_PORT_OFFSET };
73
+ }
74
+
75
+ export function getPortRange(kind) {
76
+ const portRange = PORT_RANGES.find((range) => range.kinds.includes(kind));
77
+ if (!portRange) throw new Error(`kmwc: no port range for registry kind "${kind}"`);
78
+ return portRange;
79
+ }
80
+
81
+ /** The next free block in the kind's range (after the highest block used by that range). */
82
+ export function getNextPortBlock(registry = getRegistry(), kind = 'component') {
83
+ const portRange = getPortRange(kind);
84
+ const usedBlocks = registry.components
85
+ .filter((entry) => portRange.kinds.includes(entry.kind) && typeof entry.port === 'number')
86
+ .map((entry) => entry.port);
87
+ const nextBlock = usedBlocks.length ? Math.max(...usedBlocks) + PORT_BLOCK_SIZE : portRange.firstBlock;
88
+ if (nextBlock > portRange.lastBlock) throw new Error(`kmwc: the ${portRange.kinds.join('/')} port range ${portRange.firstBlock}–${portRange.lastBlock} is full`);
89
+ return nextBlock;
90
+ }
91
+
92
+ /**
93
+ * Files that must never reach a consumer's node_modules (`kmwc doctor` checks
94
+ * the `npm pack` file list). AI-assistant guidance (CLAUDE.md / .claude/)
95
+ * slips in whenever a `files` entry is a directory glob like `src/css`, so the
96
+ * scaffold's allowlist excludes it with PACKAGE_FILES_EXCLUSIONS.
97
+ */
98
+ export const PACKAGE_LEAK_RULES = [
99
+ { pattern: /(^|\/)CLAUDE\.md$/i, label: 'CLAUDE.md (AI-assistant guidance)' },
100
+ { pattern: /(^|\/)\.claude\//, label: '.claude/ (AI-assistant commands)' },
101
+ { pattern: /^(test|tests|e2e)\//, label: 'test / e2e sources' },
102
+ { pattern: /\.(spec|test)\.[cm]?[jt]sx?$/, label: '*.spec / *.test files' },
103
+ { pattern: /^(examples-[^/]*\.html|index\.html|partials\/)/, label: 'example pages' },
104
+ { pattern: /^(dist-examples|test-results|playwright-report)\//, label: 'build / test output' },
105
+ { pattern: /(^|\/)(\.makefile\.env|Makefile\.local)$/, label: 'local make config' },
106
+ ];
107
+
108
+ /** Negated `files` entries the scaffold adds (npm applies them after the globs). */
109
+ export const PACKAGE_FILES_EXCLUSIONS = ['!**/CLAUDE.md', '!**/.claude'];
110
+
111
+ /** Root-level HTML files that are fragments, not pages (never built as entries). */
112
+ export const NON_PAGE_HTML_FILES = ['plausible-snippet.html'];
113
+
114
+ /**
115
+ * Peer dependencies (vite, @playwright/test) must come from the COMPONENT's
116
+ * node_modules, not the devkit's: when the devkit is a `file:`/`npm link`
117
+ * symlink, Node resolves bare imports from the devkit's real path, which would
118
+ * load a second copy (or none). Playwright in particular breaks if the config
119
+ * and the specs see different instances.
120
+ */
121
+ export function getPeerModule(root, moduleId) {
122
+ return createRequire(join(resolve(root), 'package.json'))(moduleId);
123
+ }
124
+
125
+ export async function loadPeerModule(root, moduleId) {
126
+ const requireFromRoot = createRequire(join(resolve(root), 'package.json'));
127
+ return import(pathToFileURL(requireFromRoot.resolve(moduleId)).href);
128
+ }
129
+
130
+ export function getJsonFile(filePath) {
131
+ return JSON.parse(readFileSync(filePath, 'utf8'));
132
+ }
133
+
134
+ export function getDevkitPackageJson() {
135
+ return getJsonFile(join(DEVKIT_ROOT, 'package.json'));
136
+ }
137
+
138
+ export function getRegistry() {
139
+ return getJsonFile(join(DEVKIT_ROOT, 'components.json'));
140
+ }
141
+
142
+ /** Strip the npm scope: `@keenmate/web-multiselect` → `web-multiselect`. */
143
+ export function getUnscopedName(packageName) {
144
+ return String(packageName || '').replace(/^@[^/]+\//, '');
145
+ }
146
+
147
+ /** `web-multiselect` → `Web Multiselect` (fallback title only — set `kmwc.title`). */
148
+ export function mapToTitleCase(id) {
149
+ return id
150
+ .split(/[-_]/)
151
+ .filter(Boolean)
152
+ .map((word) => word[0].toUpperCase() + word.slice(1))
153
+ .join(' ');
154
+ }
155
+
156
+ /** `web-multiselect` → `WebMultiselect`. */
157
+ export function mapToPascalCase(id) {
158
+ return mapToTitleCase(id).replace(/\s+/g, '');
159
+ }
160
+
161
+ function getRepositoryUrl(packageJson) {
162
+ const rawUrl = typeof packageJson.repository === 'string' ? packageJson.repository : packageJson.repository?.url;
163
+ if (!rawUrl) return '';
164
+ return rawUrl.replace(/^git\+/, '').replace(/\.git$/, '');
165
+ }
166
+
167
+ /**
168
+ * Read and resolve `package.json#kmwc` for the component rooted at `root`.
169
+ * Every field has a default, so a component only states what's specific to it.
170
+ *
171
+ * @param {string} [root] component root (defaults to `process.cwd()`)
172
+ * @returns {import('./config.js').KmwcConfig}
173
+ */
174
+ export function getKmwcConfig(root = process.cwd()) {
175
+ root = resolve(root);
176
+ const packageJsonPath = join(root, 'package.json');
177
+ if (!existsSync(packageJsonPath)) throw new Error(`kmwc: no package.json in ${root}`);
178
+ return mapKmwcConfig(getJsonFile(packageJsonPath), root);
179
+ }
180
+
181
+ /** Map a parsed package.json to the resolved config (pure — used by tests). */
182
+ export function mapKmwcConfig(packageJson, root = process.cwd()) {
183
+ const packageConfig = packageJson.kmwc || {};
184
+ const id = packageConfig.id || getUnscopedName(packageJson.name) || 'component';
185
+ const library = packageConfig.library || {};
186
+ const examples = packageConfig.examples || {};
187
+ const ports = packageConfig.ports || {};
188
+ const image = packageConfig.image || {};
189
+ const theme = packageConfig.theme || {};
190
+ const analytics = packageConfig.analytics || {};
191
+ const cssVariables = packageConfig.cssVariables || {};
192
+ const rulebook = packageConfig.rulebook || {};
193
+ const devPort = ports.dev ?? 5173;
194
+
195
+ return {
196
+ root,
197
+ hasPackageConfig: !!packageJson.kmwc,
198
+ packageJson,
199
+ id,
200
+ packageName: packageJson.name || id,
201
+ version: packageJson.version || '0.1.0',
202
+ title: packageConfig.title || mapToTitleCase(id),
203
+ tagline: packageConfig.tagline || packageJson.description || '',
204
+ tag: packageConfig.tag || id,
205
+ prefix: packageConfig.prefix || '',
206
+ repository: getRepositoryUrl(packageJson),
207
+ homepage: packageJson.homepage || '',
208
+ author: typeof packageJson.author === 'string' ? packageJson.author : packageJson.author?.name || 'Keenmate s.r.o.',
209
+ license: packageJson.license || 'MIT',
210
+ library: {
211
+ entry: library.entry || 'src/index.ts',
212
+ globalName: library.globalName || mapToPascalCase(id),
213
+ fileName: library.fileName || id,
214
+ formats: library.formats || ['es', 'umd'],
215
+ // Vite 6+ names lib CSS after the package; keep the historical `style.css`
216
+ // so consumers importing `dist/style.css` don't break on the Vite 7 move.
217
+ cssFileName: library.cssFileName || 'style',
218
+ shouldEmitTypes: library.shouldEmitTypes ?? true,
219
+ typesPluginConfig: library.typesPluginConfig || {},
220
+ shouldEmitSourcemap: library.shouldEmitSourcemap ?? false,
221
+ external: library.external || [],
222
+ },
223
+ ports: {
224
+ dev: devPort,
225
+ preview: devPort + PREVIEW_PORT_OFFSET,
226
+ image: ports.image ?? devPort + IMAGE_PORT_OFFSET,
227
+ },
228
+ theme: {
229
+ accentColor: theme.accentColor || '',
230
+ accentEndColor: theme.accentEndColor || '',
231
+ },
232
+ examples: {
233
+ outDir: examples.outDir || 'dist-examples',
234
+ canonicalOrigin: examples.canonicalOrigin || '',
235
+ staticPaths: examples.staticPaths || [],
236
+ excludedPages: examples.excludedPages || [],
237
+ storagePrefix: examples.storagePrefix || id,
238
+ },
239
+ analytics: {
240
+ plausibleSiteId: analytics.plausibleSiteId || '',
241
+ plausibleHostUrl: analytics.plausibleHostUrl || 'https://stats.keenmate.services',
242
+ },
243
+ image: {
244
+ name: image.name || `registry.km8.es/${id}-examples:prod`,
245
+ container: image.container || `${id}-examples`,
246
+ runner: image.runner || 'podman',
247
+ nodeImage: image.nodeImage || '24-alpine',
248
+ packageJsonPaths: image.packageJsonPaths || ['package.json'],
249
+ installCommand: image.installCommand || 'npm install --no-audit --no-fund',
250
+ },
251
+ cssVariables: {
252
+ manifest: cssVariables.manifest || 'component-variables.manifest.json',
253
+ sources: cssVariables.sources || ['src/css'],
254
+ internalVariables: cssVariables.internalVariables || [],
255
+ checkMode: cssVariables.checkMode || 'declared',
256
+ },
257
+ rulebook: {
258
+ path:
259
+ process.env.KMWC_RULEBOOK_PATH ||
260
+ (rulebook.path ? resolve(root, rulebook.path) : getRulebookPath(root) ?? DEFAULT_RULEBOOK_PATH),
261
+ skippedChecks: rulebook.skippedChecks || [],
262
+ },
263
+ sync: {
264
+ skippedFiles: packageConfig.sync?.skippedFiles || [],
265
+ },
266
+ };
267
+ }
268
+
269
+ /** Build-time constants every component's source may reference (`__VERSION__` …). */
270
+ export function generateBuildDefines(config) {
271
+ return {
272
+ __VERSION__: JSON.stringify(config.version),
273
+ __PACKAGE_NAME__: JSON.stringify(config.packageName),
274
+ __AUTHOR__: JSON.stringify(config.author),
275
+ __LICENSE__: JSON.stringify(config.license),
276
+ __REPOSITORY__: JSON.stringify(config.repository),
277
+ __HOMEPAGE__: JSON.stringify(config.homepage),
278
+ };
279
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Side-effect entry injected into every example page by the kmwc Vite plugin
3
+ * (as `virtual:kmwc-examples`). Mounts the page-level helpers. The shared
4
+ * styles are NOT imported here: the plugin loads them render-blocking (a
5
+ * <link> in <head> in dev, an extracted stylesheet in builds), because a CSS
6
+ * import in a module script only applies after the script runs — a flash of
7
+ * unstyled page on every reload. Using this entry without the plugin? Import
8
+ * `@keenmate/web-components-devkit/examples/style.css` yourself.
9
+ *
10
+ * Mounting is deferred one task past DOM-ready on purpose: in the production
11
+ * build Vite hoists this import INTO the page's entry module, so it evaluates
12
+ * BEFORE the page's own inline script (the reverse of dev order). Waiting a
13
+ * task lets a page that builds its sections / registry in script (e.g.
14
+ * `renderExamplesIndex`) finish first — in dev and build alike. Content
15
+ * rendered later still (after a fetch) → call `mountExamplesKit()`.
16
+ */
17
+ import { attachDomReadyHandler } from './page-config.js';
18
+ import { mountExamplesKit } from './kit.js';
19
+
20
+ attachDomReadyHandler(() => setTimeout(mountExamplesKit, 0));
@@ -0,0 +1,246 @@
1
+ /**
2
+ * CHAPTER NAV — floating "on this page" jump navigator.
3
+ *
4
+ * A tab pinned to the right edge (~1/3 down the viewport) that expands into a
5
+ * scroll-spied list of the page's chapters (every section <h2>, see
6
+ * sections.js). Pages with fewer than two chapters render nothing.
7
+ *
8
+ * Cross-page mode: if `navConfig.items` is passed (or
9
+ * `window.CHAPTER_NAV_ITEMS` is set — the examples index does this from its
10
+ * page registry) the list jumps between pages instead, with no scroll-spy.
11
+ *
12
+ * The open panel is anchored to the tab with Floating UI (bundled by Vite);
13
+ * if it fails to load, a viewport-clamped fixed placement takes over.
14
+ * Styles: css/chapter-nav.css.
15
+ */
16
+ import { ensureSectionAnchors } from './sections.js';
17
+ import { ACTIVE_CHAPTER_OFFSET_PX, CHAPTER_NAV_CLASSES, CHAPTER_NAV_PANEL_ID, DEFAULT_INDEX_HREF } from './constants.js';
18
+
19
+ let floatingUiPromise = null;
20
+ function loadFloatingUi() {
21
+ floatingUiPromise ??= import('@floating-ui/dom').catch(() => null);
22
+ return floatingUiPromise;
23
+ }
24
+
25
+ const TOGGLE_ICON_HTML =
26
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
27
+ '<line x1="8" y1="6" x2="20" y2="6"></line>' +
28
+ '<line x1="8" y1="12" x2="20" y2="12"></line>' +
29
+ '<line x1="8" y1="18" x2="20" y2="18"></line>' +
30
+ '<circle cx="4" cy="6" r="1.4"></circle>' +
31
+ '<circle cx="4" cy="12" r="1.4"></circle>' +
32
+ '<circle cx="4" cy="18" r="1.4"></circle>' +
33
+ '</svg>';
34
+
35
+ /** The mounted nav — mounting again replaces it (pages that render sections late remount). */
36
+ let mountedChapterNav = null;
37
+
38
+ /**
39
+ * Mount (or remount) the nav from the page's current sections.
40
+ * @param {object} [navConfig]
41
+ * @param {Array<{label: string, href: string}>} [navConfig.items] cross-page list
42
+ * @param {string} [navConfig.indexHref] "Back to Examples" target ('' hides it)
43
+ * @returns {{ unmount(): void } | null}
44
+ */
45
+ export function mountChapterNav(navConfig = {}) {
46
+ mountedChapterNav?.unmount();
47
+ mountedChapterNav = null;
48
+
49
+ const crossPageItems = navConfig.items || (Array.isArray(window.CHAPTER_NAV_ITEMS) ? window.CHAPTER_NAV_ITEMS : null);
50
+ const isCrossPage = !!crossPageItems;
51
+ const indexHref = navConfig.indexHref ?? DEFAULT_INDEX_HREF;
52
+
53
+ const chapters = isCrossPage
54
+ ? crossPageItems
55
+ .filter((item) => item && item.href)
56
+ .map((item, itemIndex) => ({ id: null, section: null, label: item.label || `Item ${itemIndex + 1}`, href: item.href }))
57
+ : ensureSectionAnchors().map((section) => ({ ...section, href: `#${section.id}` }));
58
+ if (chapters.length < 2) return null;
59
+
60
+ // Warm the import so the panel can anchor on first open.
61
+ loadFloatingUi();
62
+
63
+ // ── build ────────────────────────────────────────────────────────────────
64
+ const rootElement = document.createElement('div');
65
+ rootElement.className = CHAPTER_NAV_CLASSES.root;
66
+
67
+ const panelElement = document.createElement('nav');
68
+ panelElement.className = CHAPTER_NAV_CLASSES.panel;
69
+ panelElement.id = CHAPTER_NAV_PANEL_ID;
70
+ panelElement.setAttribute('aria-label', isCrossPage ? 'Examples' : 'On this page');
71
+
72
+ if (!isCrossPage && indexHref) {
73
+ const backLink = document.createElement('a');
74
+ backLink.className = CHAPTER_NAV_CLASSES.back;
75
+ backLink.href = indexHref;
76
+ backLink.textContent = '← Back to Examples';
77
+ panelElement.appendChild(backLink);
78
+ }
79
+
80
+ const titleElement = document.createElement('div');
81
+ titleElement.className = CHAPTER_NAV_CLASSES.title;
82
+ titleElement.textContent = isCrossPage ? 'Examples' : 'On this page';
83
+ panelElement.appendChild(titleElement);
84
+
85
+ const listElement = document.createElement('ul');
86
+ listElement.className = CHAPTER_NAV_CLASSES.list;
87
+ const itemElementsById = Object.create(null);
88
+ for (const chapter of chapters) {
89
+ const listItem = document.createElement('li');
90
+ const itemLink = document.createElement('a');
91
+ itemLink.className = CHAPTER_NAV_CLASSES.item;
92
+ itemLink.href = chapter.href;
93
+ itemLink.textContent = chapter.label;
94
+ itemLink.addEventListener('click', () => {
95
+ if (chapter.id) activateChapter(chapter.id);
96
+ closePanel();
97
+ });
98
+ listItem.appendChild(itemLink);
99
+ listElement.appendChild(listItem);
100
+ if (chapter.id) itemElementsById[chapter.id] = itemLink;
101
+ }
102
+ panelElement.appendChild(listElement);
103
+
104
+ const toggleButton = document.createElement('button');
105
+ toggleButton.type = 'button';
106
+ toggleButton.className = CHAPTER_NAV_CLASSES.toggle;
107
+ toggleButton.setAttribute('aria-expanded', 'false');
108
+ toggleButton.setAttribute('aria-controls', CHAPTER_NAV_PANEL_ID);
109
+ toggleButton.setAttribute('aria-label', isCrossPage ? 'Jump to an example page' : 'Jump to a section on this page');
110
+ toggleButton.innerHTML = TOGGLE_ICON_HTML;
111
+
112
+ rootElement.append(toggleButton, panelElement);
113
+ document.body.appendChild(rootElement);
114
+
115
+ // Keep the wheel inside the panel even when it can't scroll further (or
116
+ // doesn't overflow at all) — `overscroll-behavior` only covers the former.
117
+ const handlePanelWheel = (event) => {
118
+ const isAtTop = panelElement.scrollTop <= 0;
119
+ const isAtBottom = panelElement.scrollTop + panelElement.clientHeight >= panelElement.scrollHeight - 1;
120
+ if ((event.deltaY < 0 && isAtTop) || (event.deltaY > 0 && isAtBottom)) event.preventDefault();
121
+ event.stopPropagation();
122
+ };
123
+ panelElement.addEventListener('wheel', handlePanelWheel, { passive: false });
124
+
125
+ // ── positioning ──────────────────────────────────────────────────────────
126
+ let stopAutoUpdateHandler = null;
127
+ let positioningRequestId = 0;
128
+
129
+ function positionPanel() {
130
+ const requestId = ++positioningRequestId;
131
+ loadFloatingUi().then((floatingUi) => {
132
+ if (requestId !== positioningRequestId || !isPanelOpen) return; // closed before the import resolved
133
+ if (!floatingUi) return positionPanelFallback();
134
+ stopAutoUpdateHandler = floatingUi.autoUpdate(toggleButton, panelElement, () => {
135
+ floatingUi
136
+ .computePosition(toggleButton, panelElement, {
137
+ placement: 'left-start',
138
+ strategy: 'fixed',
139
+ middleware: [
140
+ floatingUi.offset(8),
141
+ floatingUi.flip({ fallbackPlacements: ['right-start', 'bottom-end', 'top-end'] }),
142
+ floatingUi.shift({ padding: 8 }),
143
+ ],
144
+ })
145
+ .then((position) => {
146
+ panelElement.style.left = `${position.x}px`;
147
+ panelElement.style.top = `${position.y}px`;
148
+ });
149
+ });
150
+ });
151
+ }
152
+
153
+ function positionPanelFallback() {
154
+ const toggleRect = toggleButton.getBoundingClientRect();
155
+ panelElement.style.left = 'auto';
156
+ panelElement.style.right = `${window.innerWidth - toggleRect.left + 8}px`;
157
+ panelElement.style.top = `${Math.max(8, Math.min(toggleRect.top, window.innerHeight - panelElement.offsetHeight - 8))}px`;
158
+ }
159
+
160
+ function stopPanelPositioning() {
161
+ positioningRequestId++;
162
+ if (stopAutoUpdateHandler) {
163
+ stopAutoUpdateHandler();
164
+ stopAutoUpdateHandler = null;
165
+ }
166
+ }
167
+
168
+ // ── open / close ─────────────────────────────────────────────────────────
169
+ let isPanelOpen = false;
170
+ function openPanel() {
171
+ isPanelOpen = true;
172
+ rootElement.classList.add(CHAPTER_NAV_CLASSES.rootOpen);
173
+ toggleButton.setAttribute('aria-expanded', 'true');
174
+ positionPanel();
175
+ }
176
+ function closePanel() {
177
+ isPanelOpen = false;
178
+ rootElement.classList.remove(CHAPTER_NAV_CLASSES.rootOpen);
179
+ toggleButton.setAttribute('aria-expanded', 'false');
180
+ stopPanelPositioning();
181
+ }
182
+ toggleButton.addEventListener('click', () => (isPanelOpen ? closePanel() : openPanel()));
183
+
184
+ const handleDocumentKeydown = (event) => {
185
+ if (event.key === 'Escape' && isPanelOpen) {
186
+ closePanel();
187
+ toggleButton.focus();
188
+ }
189
+ };
190
+ const handleDocumentClick = (event) => {
191
+ if (isPanelOpen && !rootElement.contains(event.target)) closePanel();
192
+ };
193
+ document.addEventListener('keydown', handleDocumentKeydown);
194
+ document.addEventListener('click', handleDocumentClick);
195
+
196
+ // ── scroll-spy (in-page chapters only) ───────────────────────────────────
197
+ let activeChapterId = null;
198
+ function activateChapter(chapterId) {
199
+ if (chapterId === activeChapterId) return;
200
+ itemElementsById[activeChapterId]?.classList.remove(CHAPTER_NAV_CLASSES.itemActive);
201
+ activeChapterId = chapterId;
202
+ const itemLink = itemElementsById[chapterId];
203
+ if (itemLink) {
204
+ itemLink.classList.add(CHAPTER_NAV_CLASSES.itemActive);
205
+ itemLink.scrollIntoView?.({ block: 'nearest' });
206
+ }
207
+ }
208
+ function reconcileActiveChapter() {
209
+ // At the bottom of the page the last chapters may never reach the top
210
+ // threshold (short page / short trailing sections) — the last one wins.
211
+ const isAtPageBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
212
+ if (isAtPageBottom && window.scrollY > 0) return activateChapter(chapters[chapters.length - 1].id);
213
+ let currentChapterId = chapters[0].id;
214
+ for (const chapter of chapters) {
215
+ if (chapter.section.getBoundingClientRect().top <= ACTIVE_CHAPTER_OFFSET_PX) currentChapterId = chapter.id;
216
+ else break;
217
+ }
218
+ activateChapter(currentChapterId);
219
+ }
220
+ let isFrameScheduled = false;
221
+ const handleScroll = () => {
222
+ if (isFrameScheduled) return;
223
+ isFrameScheduled = true;
224
+ requestAnimationFrame(() => {
225
+ reconcileActiveChapter();
226
+ isFrameScheduled = false;
227
+ });
228
+ };
229
+ if (!isCrossPage) {
230
+ window.addEventListener('scroll', handleScroll, { passive: true });
231
+ window.addEventListener('resize', handleScroll, { passive: true });
232
+ reconcileActiveChapter();
233
+ }
234
+
235
+ mountedChapterNav = {
236
+ unmount() {
237
+ stopPanelPositioning();
238
+ document.removeEventListener('keydown', handleDocumentKeydown);
239
+ document.removeEventListener('click', handleDocumentClick);
240
+ window.removeEventListener('scroll', handleScroll);
241
+ window.removeEventListener('resize', handleScroll);
242
+ rootElement.remove();
243
+ },
244
+ };
245
+ return mountedChapterNav;
246
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Side layer: every class name, selector, id and storage key the examples kit
3
+ * emits or reads. Kit-generated DOM follows the rulebook's prefix + BEM rule
4
+ * (`kmwc__element--modifier` shape, block `kmwc-<feature>`); the page
5
+ * vocabulary authors write (`.card`, `.controls`, …) is deliberately NOT here
6
+ * — it's styled by the kit but owned by the pages.
7
+ */
8
+
9
+ export const CHAPTER_NAV_CLASSES = {
10
+ root: 'kmwc-chapter-nav',
11
+ rootOpen: 'kmwc-chapter-nav--open',
12
+ toggle: 'kmwc-chapter-nav__toggle',
13
+ panel: 'kmwc-chapter-nav__panel',
14
+ back: 'kmwc-chapter-nav__back',
15
+ title: 'kmwc-chapter-nav__title',
16
+ list: 'kmwc-chapter-nav__list',
17
+ item: 'kmwc-chapter-nav__item',
18
+ itemActive: 'kmwc-chapter-nav__item--active',
19
+ };
20
+
21
+ export const CHAPTER_NAV_PANEL_ID = 'kmwc-chapter-nav-panel';
22
+
23
+ export const COPY_LINK_CLASSES = {
24
+ root: 'kmwc-copy-link',
25
+ rootCopied: 'kmwc-copy-link--copied',
26
+ rootFailed: 'kmwc-copy-link--failed',
27
+ icon: 'kmwc-copy-link__icon',
28
+ iconLink: 'kmwc-copy-link__icon--link',
29
+ iconCheck: 'kmwc-copy-link__icon--check',
30
+ label: 'kmwc-copy-link__label',
31
+ };
32
+
33
+ export const INDEX_PAGE_CLASSES = {
34
+ body: 'kmwc-index',
35
+ page: 'kmwc-index__page',
36
+ header: 'kmwc-index__header',
37
+ tagline: 'kmwc-index__tagline',
38
+ version: 'kmwc-index__version',
39
+ cards: 'kmwc-index__cards',
40
+ card: 'kmwc-index__card',
41
+ icon: 'kmwc-index__icon',
42
+ cardTitle: 'kmwc-index__card-title',
43
+ cardDescription: 'kmwc-index__card-description',
44
+ badge: 'kmwc-index__badge',
45
+ footer: 'kmwc-index__footer',
46
+ devkit: 'kmwc-index__devkit',
47
+ };
48
+
49
+ /** <kmwc-demo> output (rendered at build time by page-composer.js) + the kit's copy button. */
50
+ export const DEMO_CLASSES = {
51
+ root: 'kmwc-demo',
52
+ preview: 'kmwc-demo__preview',
53
+ code: 'kmwc-demo__code',
54
+ copyButton: 'kmwc-demo__copy',
55
+ copyButtonCopied: 'kmwc-demo__copy--copied',
56
+ };
57
+
58
+ export const THEME_SWITCH_CLASSES = {
59
+ root: 'kmwc-theme-switch',
60
+ icon: 'kmwc-theme-switch__icon',
61
+ };
62
+
63
+ /** Section containers and the chips that must not leak into a heading label. */
64
+ export const SECTION_CONTAINER_SELECTOR = '.container';
65
+ export const SECTION_SELECTOR = '.card, .example-section';
66
+ export const HEADING_CHIP_SELECTOR = `.badge, .${COPY_LINK_CLASSES.root}`;
67
+
68
+ export const VERSION_BADGE_SELECTOR = '[data-kmwc-version], #version-badge';
69
+
70
+ export const DEFAULT_INDEX_HREF = 'index.html';
71
+
72
+ /** Page theme: `data-theme` on <html> — the same signal components react to. */
73
+ export const THEME_ATTRIBUTE = 'theme';
74
+ export const THEME_STORAGE_KEY = 'kmwc:examples:theme';
75
+ export const THEMES = ['light', 'dark', 'auto'];
76
+ export const DEFAULT_THEME = 'light';
77
+
78
+ /** A chapter counts as "current" once its top is within this many px of the viewport top. */
79
+ export const ACTIVE_CHAPTER_OFFSET_PX = 120;
80
+ export const COPY_FEEDBACK_DURATION_MS = 1600;