@oddkin/workbench 0.0.0-stage → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/dist/app/card-previews.d.ts +14 -0
- package/dist/app/card-previews.d.ts.map +1 -0
- package/dist/app/card-previews.js +140 -0
- package/dist/app/card-previews.js.map +1 -0
- package/dist/app/copy.d.ts +48 -0
- package/dist/app/copy.d.ts.map +1 -0
- package/dist/app/copy.js +52 -0
- package/dist/app/copy.js.map +1 -0
- package/dist/app/define.d.ts +3 -0
- package/dist/app/define.d.ts.map +1 -0
- package/dist/app/define.js +10 -0
- package/dist/app/define.js.map +1 -0
- package/dist/app/details.d.ts +22 -0
- package/dist/app/details.d.ts.map +1 -0
- package/dist/app/details.js +17 -0
- package/dist/app/details.js.map +1 -0
- package/dist/app/index.d.ts +12 -0
- package/dist/app/index.d.ts.map +1 -0
- package/dist/app/index.js +9 -0
- package/dist/app/index.js.map +1 -0
- package/dist/app/routes.d.ts +26 -0
- package/dist/app/routes.d.ts.map +1 -0
- package/dist/app/routes.js +96 -0
- package/dist/app/routes.js.map +1 -0
- package/dist/app/search.d.ts +17 -0
- package/dist/app/search.d.ts.map +1 -0
- package/dist/app/search.js +52 -0
- package/dist/app/search.js.map +1 -0
- package/dist/app/shell.d.ts +29 -0
- package/dist/app/shell.d.ts.map +1 -0
- package/dist/app/shell.js +478 -0
- package/dist/app/shell.js.map +1 -0
- package/dist/app/styles.d.ts +2 -0
- package/dist/app/styles.d.ts.map +1 -0
- package/dist/app/styles.js +356 -0
- package/dist/app/styles.js.map +1 -0
- package/dist/app/theme-studio.d.ts +12 -0
- package/dist/app/theme-studio.d.ts.map +1 -0
- package/dist/app/theme-studio.js +14 -0
- package/dist/app/theme-studio.js.map +1 -0
- package/dist/app/views.d.ts +38 -0
- package/dist/app/views.d.ts.map +1 -0
- package/dist/app/views.js +252 -0
- package/dist/app/views.js.map +1 -0
- package/dist/config/index.d.ts +170 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +148 -0
- package/dist/config/index.js.map +1 -0
- package/dist/conformance/button.d.ts +2 -0
- package/dist/conformance/button.d.ts.map +1 -0
- package/dist/conformance/button.js +233 -0
- package/dist/conformance/button.js.map +1 -0
- package/dist/conformance/index.d.ts +107 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +60 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/data/build.d.ts +36 -0
- package/dist/data/build.d.ts.map +1 -0
- package/dist/data/build.js +260 -0
- package/dist/data/build.js.map +1 -0
- package/dist/data/import-map.d.ts +59 -0
- package/dist/data/import-map.d.ts.map +1 -0
- package/dist/data/import-map.js +195 -0
- package/dist/data/import-map.js.map +1 -0
- package/dist/data/index.d.ts +14 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +10 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/theme-studio.d.ts +21 -0
- package/dist/data/theme-studio.d.ts.map +1 -0
- package/dist/data/theme-studio.js +112 -0
- package/dist/data/theme-studio.js.map +1 -0
- package/dist/data/types.d.ts +187 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/data/types.js +4 -0
- package/dist/data/types.js.map +1 -0
- package/dist/examples/index.d.ts +101 -0
- package/dist/examples/index.d.ts.map +1 -0
- package/dist/examples/index.js +106 -0
- package/dist/examples/index.js.map +1 -0
- package/dist/frame/index.d.ts +9 -0
- package/dist/frame/index.d.ts.map +1 -0
- package/dist/frame/index.js +482 -0
- package/dist/frame/index.js.map +1 -0
- package/dist/internal/diagnostics.d.ts +17 -0
- package/dist/internal/diagnostics.d.ts.map +1 -0
- package/dist/internal/diagnostics.js +19 -0
- package/dist/internal/diagnostics.js.map +1 -0
- package/dist/node/bin.d.ts +3 -0
- package/dist/node/bin.d.ts.map +1 -0
- package/dist/node/bin.js +12 -0
- package/dist/node/bin.js.map +1 -0
- package/dist/node/cli.d.ts +17 -0
- package/dist/node/cli.d.ts.map +1 -0
- package/dist/node/cli.js +198 -0
- package/dist/node/cli.js.map +1 -0
- package/dist/node/config.d.ts +28 -0
- package/dist/node/config.d.ts.map +1 -0
- package/dist/node/config.js +80 -0
- package/dist/node/config.js.map +1 -0
- package/dist/node/example-modules.d.ts +8 -0
- package/dist/node/example-modules.d.ts.map +1 -0
- package/dist/node/example-modules.js +28 -0
- package/dist/node/example-modules.js.map +1 -0
- package/dist/node/generated.d.ts +19 -0
- package/dist/node/generated.d.ts.map +1 -0
- package/dist/node/generated.js +109 -0
- package/dist/node/generated.js.map +1 -0
- package/dist/node/html.d.ts +35 -0
- package/dist/node/html.d.ts.map +1 -0
- package/dist/node/html.js +51 -0
- package/dist/node/html.js.map +1 -0
- package/dist/node/index.d.ts +24 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +15 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/inputs.d.ts +26 -0
- package/dist/node/inputs.d.ts.map +1 -0
- package/dist/node/inputs.js +282 -0
- package/dist/node/inputs.js.map +1 -0
- package/dist/node/packages.d.ts +35 -0
- package/dist/node/packages.d.ts.map +1 -0
- package/dist/node/packages.js +87 -0
- package/dist/node/packages.js.map +1 -0
- package/dist/node/preload.d.ts +10 -0
- package/dist/node/preload.d.ts.map +1 -0
- package/dist/node/preload.js +61 -0
- package/dist/node/preload.js.map +1 -0
- package/dist/node/server.d.ts +42 -0
- package/dist/node/server.d.ts.map +1 -0
- package/dist/node/server.js +410 -0
- package/dist/node/server.js.map +1 -0
- package/dist/node/static.d.ts +43 -0
- package/dist/node/static.d.ts.map +1 -0
- package/dist/node/static.js +157 -0
- package/dist/node/static.js.map +1 -0
- package/dist/node/workbench.d.ts +43 -0
- package/dist/node/workbench.d.ts.map +1 -0
- package/dist/node/workbench.js +204 -0
- package/dist/node/workbench.js.map +1 -0
- package/dist/pages/build.d.ts +22 -0
- package/dist/pages/build.d.ts.map +1 -0
- package/dist/pages/build.js +363 -0
- package/dist/pages/build.js.map +1 -0
- package/dist/pages/defaults.d.ts +16 -0
- package/dist/pages/defaults.d.ts.map +1 -0
- package/dist/pages/defaults.js +88 -0
- package/dist/pages/defaults.js.map +1 -0
- package/dist/pages/index.d.ts +14 -0
- package/dist/pages/index.d.ts.map +1 -0
- package/dist/pages/index.js +10 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/navigation.d.ts +48 -0
- package/dist/pages/navigation.d.ts.map +1 -0
- package/dist/pages/navigation.js +83 -0
- package/dist/pages/navigation.js.map +1 -0
- package/dist/pages/types.d.ts +148 -0
- package/dist/pages/types.d.ts.map +1 -0
- package/dist/pages/types.js +3 -0
- package/dist/pages/types.js.map +1 -0
- package/dist/panels/catalog-define.d.ts +3 -0
- package/dist/panels/catalog-define.d.ts.map +1 -0
- package/dist/panels/catalog-define.js +7 -0
- package/dist/panels/catalog-define.js.map +1 -0
- package/dist/panels/catalog-layout-styles.d.ts +2 -0
- package/dist/panels/catalog-layout-styles.d.ts.map +1 -0
- package/dist/panels/catalog-layout-styles.js +54 -0
- package/dist/panels/catalog-layout-styles.js.map +1 -0
- package/dist/panels/catalog-styles.d.ts +2 -0
- package/dist/panels/catalog-styles.d.ts.map +1 -0
- package/dist/panels/catalog-styles.js +106 -0
- package/dist/panels/catalog-styles.js.map +1 -0
- package/dist/panels/catalog.d.ts +37 -0
- package/dist/panels/catalog.d.ts.map +1 -0
- package/dist/panels/catalog.js +74 -0
- package/dist/panels/catalog.js.map +1 -0
- package/dist/panels/component-define.d.ts +3 -0
- package/dist/panels/component-define.d.ts.map +1 -0
- package/dist/panels/component-define.js +6 -0
- package/dist/panels/component-define.js.map +1 -0
- package/dist/panels/component-detail.d.ts +49 -0
- package/dist/panels/component-detail.d.ts.map +1 -0
- package/dist/panels/component-detail.js +1352 -0
- package/dist/panels/component-detail.js.map +1 -0
- package/dist/panels/component.d.ts +17 -0
- package/dist/panels/component.d.ts.map +1 -0
- package/dist/panels/component.js +10 -0
- package/dist/panels/component.js.map +1 -0
- package/dist/panels/example-styles.d.ts +2 -0
- package/dist/panels/example-styles.d.ts.map +1 -0
- package/dist/panels/example-styles.js +343 -0
- package/dist/panels/example-styles.js.map +1 -0
- package/dist/panels/foundation-define.d.ts +3 -0
- package/dist/panels/foundation-define.d.ts.map +1 -0
- package/dist/panels/foundation-define.js +6 -0
- package/dist/panels/foundation-define.js.map +1 -0
- package/dist/panels/foundation-detail.d.ts +23 -0
- package/dist/panels/foundation-detail.d.ts.map +1 -0
- package/dist/panels/foundation-detail.js +216 -0
- package/dist/panels/foundation-detail.js.map +1 -0
- package/dist/panels/foundation.d.ts +15 -0
- package/dist/panels/foundation.d.ts.map +1 -0
- package/dist/panels/foundation.js +9 -0
- package/dist/panels/foundation.js.map +1 -0
- package/dist/panels/model.d.ts +184 -0
- package/dist/panels/model.d.ts.map +1 -0
- package/dist/panels/model.js +488 -0
- package/dist/panels/model.js.map +1 -0
- package/dist/panels/styles.d.ts +3 -0
- package/dist/panels/styles.d.ts.map +1 -0
- package/dist/panels/styles.js +265 -0
- package/dist/panels/styles.js.map +1 -0
- package/dist/panels/theme-model.d.ts +69 -0
- package/dist/panels/theme-model.d.ts.map +1 -0
- package/dist/panels/theme-model.js +166 -0
- package/dist/panels/theme-model.js.map +1 -0
- package/dist/panels/theme-studio.d.ts +32 -0
- package/dist/panels/theme-studio.d.ts.map +1 -0
- package/dist/panels/theme-studio.js +375 -0
- package/dist/panels/theme-studio.js.map +1 -0
- package/dist/preview/frame.d.ts +44 -0
- package/dist/preview/frame.d.ts.map +1 -0
- package/dist/preview/frame.js +260 -0
- package/dist/preview/frame.js.map +1 -0
- package/dist/preview/index.d.ts +26 -0
- package/dist/preview/index.d.ts.map +1 -0
- package/dist/preview/index.js +58 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/preview/theme-generation.d.ts +33 -0
- package/dist/preview/theme-generation.d.ts.map +1 -0
- package/dist/preview/theme-generation.js +48 -0
- package/dist/preview/theme-generation.js.map +1 -0
- package/dist/protocol/index.d.ts +222 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +272 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/testing/index.d.ts +47 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +145 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +118 -4
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component detail content (workbench-pages.md §7): every section of a component page, with the
|
|
3
|
+
* editable API table as the only control surface over one isolated preview (or a light/dark
|
|
4
|
+
* pair). Rendered by the public `<oddkin-component-panel>` (open shadow root) and by the shell
|
|
5
|
+
* (light DOM, so "On this page" fragments resolve).
|
|
6
|
+
*/
|
|
7
|
+
import { OddkinElement, prop } from '@oddkin/core/element';
|
|
8
|
+
import { signal, untracked } from '@oddkin/core/reactive';
|
|
9
|
+
import { createScope } from '@oddkin/core/scope';
|
|
10
|
+
import { html, nothing, repeat } from '@oddkin/core/template';
|
|
11
|
+
import { createIsolatedPreview, PreviewFrameError, } from '../preview/frame.js';
|
|
12
|
+
import { isCustomPropertyName, } from '../protocol/index.js';
|
|
13
|
+
import { authoredValues, componentFacts, decodeShare, defaultWriteMode, encodeShare, initialState, intendedUse, pageCoverage, presets, registrationOf, usagePairs, MAX_SHARE_WIDTH, MIN_SHARE_WIDTH, SHARE_PARAM, sharePayload, snippet, } from './model.js';
|
|
14
|
+
import { contentStyles, panelHostStyles } from './styles.js';
|
|
15
|
+
import { exampleStyles } from './example-styles.js';
|
|
16
|
+
const LOG_LIMIT = 50;
|
|
17
|
+
const WIDTHS = {
|
|
18
|
+
narrow: 320,
|
|
19
|
+
medium: 640,
|
|
20
|
+
wide: 960,
|
|
21
|
+
};
|
|
22
|
+
const DEFAULT_SETTINGS = {
|
|
23
|
+
mode: 'site',
|
|
24
|
+
pair: false,
|
|
25
|
+
direction: 'ltr',
|
|
26
|
+
width: 'fill',
|
|
27
|
+
customWidth: 480,
|
|
28
|
+
};
|
|
29
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
31
|
+
/** Text with Markdown code spans shown as code; everything stays text, never markup. */
|
|
32
|
+
function inline(text) {
|
|
33
|
+
const parts = text.split('`');
|
|
34
|
+
if (parts.length % 2 === 0)
|
|
35
|
+
parts[parts.length - 2] += `\`${parts.pop()}`;
|
|
36
|
+
return parts.map((part, i) => (i % 2 ? html `<code>${part}</code>` : part));
|
|
37
|
+
}
|
|
38
|
+
const notDocumented = (key, subject) => {
|
|
39
|
+
const lead = `${subject ? `${subject}: not documented` : 'Not documented'}. Supplied by `;
|
|
40
|
+
return html `<p class="wb-not-documented">${lead}<code>${key}</code>.</p>`;
|
|
41
|
+
};
|
|
42
|
+
const bullets = (items) => html `<ul class="wb-list">
|
|
43
|
+
${items.map((item) => html `<li>${inline(item)}</li>`)}
|
|
44
|
+
</ul>`;
|
|
45
|
+
/** The reference layout's links to its sections (Figma 19:874), by section id. */
|
|
46
|
+
const PAGE_LINKS = [
|
|
47
|
+
['overview', 'Overview'],
|
|
48
|
+
['interactive-example', 'Controls'],
|
|
49
|
+
['examples', 'Examples'],
|
|
50
|
+
['behavior-and-accessibility', 'Accessibility'],
|
|
51
|
+
];
|
|
52
|
+
/** `'secondary'` reads as `secondary`; an empty literal stays as written. */
|
|
53
|
+
const unquote = (value) => (/^(['"`]).+\1$/.test(value) ? value.slice(1, -1) : value);
|
|
54
|
+
/** A row's name cell: the name, and its description or "Not documented". */
|
|
55
|
+
const named = (name, description) => html `<th scope="row" data-label="Name">
|
|
56
|
+
<code>${name}</code>
|
|
57
|
+
<small
|
|
58
|
+
>${description ? inline(description) : html `<span class="wb-not-documented">Not documented</span>`}</small
|
|
59
|
+
>
|
|
60
|
+
</th>`;
|
|
61
|
+
function covered(coverage, quiet) {
|
|
62
|
+
const byId = new Map(coverage.map((item) => [item.id, item]));
|
|
63
|
+
return (id, content) => {
|
|
64
|
+
const item = byId.get(id);
|
|
65
|
+
if (!item || (quiet && item.state !== 'documented'))
|
|
66
|
+
return nothing;
|
|
67
|
+
return html `<div data-coverage=${id} data-state=${item.state}>
|
|
68
|
+
${item.state === 'documented'
|
|
69
|
+
? content()
|
|
70
|
+
: item.state === 'none'
|
|
71
|
+
? html `<p class="wb-muted">${item.empty}</p>`
|
|
72
|
+
: notDocumented(item.source ?? '', item.subject)}
|
|
73
|
+
</div>`;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export class ComponentDetailElement extends OddkinElement.with({
|
|
77
|
+
page: prop.object({ description: 'The component page data.' }),
|
|
78
|
+
index: prop.object({ description: 'The Workbench index.' }),
|
|
79
|
+
href: prop.custom({
|
|
80
|
+
default: () => (route) => route,
|
|
81
|
+
coerce: (value) => typeof value === 'function'
|
|
82
|
+
? { ok: true, value: value }
|
|
83
|
+
: { ok: false, reason: 'expected a function' },
|
|
84
|
+
description: 'Maps a Workbench route to a URL.',
|
|
85
|
+
}),
|
|
86
|
+
frameUrl: prop.string({
|
|
87
|
+
default: '',
|
|
88
|
+
attribute: false,
|
|
89
|
+
description: 'URL of the preview frame document.',
|
|
90
|
+
}),
|
|
91
|
+
layout: prop.enum(['workbench', 'reference'], {
|
|
92
|
+
default: 'workbench',
|
|
93
|
+
description: "'reference' is the documentation page (Figma 20:2190): links to the page's sections, no preview tools (presets, settings, example link, event log), and absent items left out instead of labelled.",
|
|
94
|
+
}),
|
|
95
|
+
examplePreview: prop.custom({
|
|
96
|
+
default: () => undefined,
|
|
97
|
+
coerce: (value) => value === undefined || typeof value === 'function'
|
|
98
|
+
? {
|
|
99
|
+
ok: true,
|
|
100
|
+
value: value,
|
|
101
|
+
}
|
|
102
|
+
: { ok: false, reason: 'expected a function' },
|
|
103
|
+
description: 'Returns a live element for an example card (the example id, `example:<tag>/<name>`). Only a host that loads the library supplies one; the same example should get the same node.',
|
|
104
|
+
}),
|
|
105
|
+
sections: prop.custom({
|
|
106
|
+
default: () => null,
|
|
107
|
+
coerce: (value) => value === null || value === undefined
|
|
108
|
+
? { ok: true, value: null }
|
|
109
|
+
: Array.isArray(value) && value.every((id) => typeof id === 'string')
|
|
110
|
+
? { ok: true, value: Object.freeze([...value]) }
|
|
111
|
+
: { ok: false, reason: 'expected an array of section ids, or null' },
|
|
112
|
+
description: "The section ids to show, for a host's tabs (overview, interactive-example, api, examples, tokens, behavior-and-accessibility, guidance, source, known-limitations); null shows every section. The others stay rendered but hidden, so the example's preview and controls survive a switch, and the panel leaves out its own section links.",
|
|
113
|
+
}),
|
|
114
|
+
}) {
|
|
115
|
+
static styles = [panelHostStyles, contentStyles, exampleStyles];
|
|
116
|
+
#state = signal({
|
|
117
|
+
preset: 'default',
|
|
118
|
+
attributes: {},
|
|
119
|
+
properties: {},
|
|
120
|
+
slots: {},
|
|
121
|
+
});
|
|
122
|
+
#modes = signal({});
|
|
123
|
+
#errors = signal({});
|
|
124
|
+
#settings = signal(DEFAULT_SETTINGS);
|
|
125
|
+
#log = signal([]);
|
|
126
|
+
#status = signal({
|
|
127
|
+
text: '',
|
|
128
|
+
alert: false,
|
|
129
|
+
});
|
|
130
|
+
#link = signal('');
|
|
131
|
+
/** The generated code is shown under the example. */
|
|
132
|
+
#code = signal(false);
|
|
133
|
+
/** The section the reference layout's links mark as current. */
|
|
134
|
+
#current = signal('');
|
|
135
|
+
/** The reference layout shows every example card, not the first four. */
|
|
136
|
+
#allExamples = signal(false);
|
|
137
|
+
#hosts;
|
|
138
|
+
#previews = [];
|
|
139
|
+
#previewScope;
|
|
140
|
+
#key = '';
|
|
141
|
+
#logId = 0;
|
|
142
|
+
constructor() {
|
|
143
|
+
super();
|
|
144
|
+
this.#hosts = [document.createElement('div'), document.createElement('div')];
|
|
145
|
+
}
|
|
146
|
+
/** Current preview configuration (for tests and embedding); read-only snapshot. */
|
|
147
|
+
get previewState() {
|
|
148
|
+
return this.#state.peek();
|
|
149
|
+
}
|
|
150
|
+
/** Live preview frames, in order (light first when shown side by side). */
|
|
151
|
+
get previews() {
|
|
152
|
+
return this.#previews;
|
|
153
|
+
}
|
|
154
|
+
connected(scope) {
|
|
155
|
+
scope.effect(() => {
|
|
156
|
+
const page = this.page;
|
|
157
|
+
const frameUrl = this.frameUrl;
|
|
158
|
+
untracked(() => this.#sync(page, frameUrl));
|
|
159
|
+
});
|
|
160
|
+
scope.add(() => {
|
|
161
|
+
this.#key = '';
|
|
162
|
+
this.#stop();
|
|
163
|
+
});
|
|
164
|
+
let frame = 0;
|
|
165
|
+
scope.listen(this.ownerDocument, 'scroll', () => {
|
|
166
|
+
if (this.layout === 'reference' && !frame)
|
|
167
|
+
frame = requestAnimationFrame(() => {
|
|
168
|
+
frame = 0;
|
|
169
|
+
this.#spy();
|
|
170
|
+
});
|
|
171
|
+
}, { capture: true, passive: true });
|
|
172
|
+
scope.add(() => cancelAnimationFrame(frame));
|
|
173
|
+
}
|
|
174
|
+
/** The current section is the last linked one whose top has passed a third of the view. */
|
|
175
|
+
#spy() {
|
|
176
|
+
const view = this.ownerDocument.defaultView;
|
|
177
|
+
const page = this.ownerDocument.scrollingElement;
|
|
178
|
+
if (!view || !page)
|
|
179
|
+
return;
|
|
180
|
+
const linked = PAGE_LINKS.flatMap(([id]) => this.shadowRoot?.getElementById(id) ?? []);
|
|
181
|
+
const atEnd = page.scrollTop + view.innerHeight >= page.scrollHeight - 2;
|
|
182
|
+
let current = linked[0]?.id ?? '';
|
|
183
|
+
for (const section of linked)
|
|
184
|
+
if (atEnd || section.getBoundingClientRect().top <= view.innerHeight / 3)
|
|
185
|
+
current = section.id;
|
|
186
|
+
this.#current.value = current;
|
|
187
|
+
}
|
|
188
|
+
/** Scroll a section into view and move focus to its heading. */
|
|
189
|
+
#jump(event, id) {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
const section = this.shadowRoot?.getElementById(id);
|
|
192
|
+
section?.scrollIntoView({ block: 'start' });
|
|
193
|
+
section?.querySelector('h2')?.focus({ preventScroll: true });
|
|
194
|
+
this.#current.value = id;
|
|
195
|
+
}
|
|
196
|
+
/** Restart the preview only when the component, registration, or frame document changes. */
|
|
197
|
+
#sync(page, frameUrl) {
|
|
198
|
+
const registration = page && registrationOf(page);
|
|
199
|
+
const key = page ? `${page.tag}\n${String(registration)}\n${frameUrl}` : '';
|
|
200
|
+
if (key === this.#key)
|
|
201
|
+
return;
|
|
202
|
+
const sameTag = this.#key.split('\n')[0] === page?.tag;
|
|
203
|
+
this.#key = key;
|
|
204
|
+
if (!page) {
|
|
205
|
+
this.#stop();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!sameTag) {
|
|
209
|
+
this.#modes.value = {};
|
|
210
|
+
this.#errors.value = {};
|
|
211
|
+
this.#log.value = [];
|
|
212
|
+
this.#link.value = '';
|
|
213
|
+
this.#state.value = this.#fromLocation(page) ?? initialState(page);
|
|
214
|
+
}
|
|
215
|
+
this.#restart();
|
|
216
|
+
}
|
|
217
|
+
#fromLocation(page) {
|
|
218
|
+
const location = this.ownerDocument.defaultView?.location;
|
|
219
|
+
const hashQuery = location?.hash.includes('?')
|
|
220
|
+
? location.hash.slice(location.hash.indexOf('?'))
|
|
221
|
+
: '';
|
|
222
|
+
const encoded = new URLSearchParams(location?.search ?? '').get(SHARE_PARAM) ??
|
|
223
|
+
new URLSearchParams(hashQuery.split('#')[0]).get(SHARE_PARAM);
|
|
224
|
+
if (!encoded)
|
|
225
|
+
return undefined;
|
|
226
|
+
const { state, preview, problems } = decodeShare(page, encoded);
|
|
227
|
+
if (problems.length)
|
|
228
|
+
this.#status.value = { text: problems.join(' '), alert: !state, keep: true };
|
|
229
|
+
if (!state)
|
|
230
|
+
return undefined;
|
|
231
|
+
if (preview) {
|
|
232
|
+
const width = preview.width;
|
|
233
|
+
const named = typeof width === 'number'
|
|
234
|
+
? Object.keys(WIDTHS).find((key) => WIDTHS[key] === width)
|
|
235
|
+
: undefined;
|
|
236
|
+
this.#settings.value = {
|
|
237
|
+
...DEFAULT_SETTINGS,
|
|
238
|
+
mode: preview.mode ?? 'site',
|
|
239
|
+
direction: preview.direction ?? 'ltr',
|
|
240
|
+
...(typeof width !== 'number'
|
|
241
|
+
? {}
|
|
242
|
+
: named
|
|
243
|
+
? { width: named }
|
|
244
|
+
: { width: 'custom', customWidth: width }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return state;
|
|
248
|
+
}
|
|
249
|
+
#stop() {
|
|
250
|
+
this.#previewScope?.dispose();
|
|
251
|
+
this.#previewScope = undefined;
|
|
252
|
+
this.#previews = [];
|
|
253
|
+
for (const host of this.#hosts)
|
|
254
|
+
host.replaceChildren();
|
|
255
|
+
}
|
|
256
|
+
#frameSettings(index) {
|
|
257
|
+
const settings = this.#settings.peek();
|
|
258
|
+
const mode = settings.pair ? (index === 0 ? 'light' : 'dark') : settings.mode;
|
|
259
|
+
const width = settings.width === 'fill'
|
|
260
|
+
? undefined
|
|
261
|
+
: settings.width === 'custom'
|
|
262
|
+
? settings.customWidth
|
|
263
|
+
: WIDTHS[settings.width];
|
|
264
|
+
return {
|
|
265
|
+
...(mode === 'site' ? {} : { mode }),
|
|
266
|
+
direction: settings.direction,
|
|
267
|
+
...(width ? { container: { width } } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
#mountPayload(page, registration, index) {
|
|
271
|
+
const state = this.#state.peek();
|
|
272
|
+
const facts = componentFacts(page);
|
|
273
|
+
const fixture = presets(page).some((p) => p.name === state.preset && p.fixture);
|
|
274
|
+
return {
|
|
275
|
+
tag: page.tag,
|
|
276
|
+
registration,
|
|
277
|
+
example: `example:${page.tag}/${state.preset}`,
|
|
278
|
+
properties: state.properties,
|
|
279
|
+
attributes: state.attributes,
|
|
280
|
+
slots: state.slots,
|
|
281
|
+
styles: state.styles ?? {},
|
|
282
|
+
...(fixture && page.exampleModule ? { fixture: page.exampleModule } : {}),
|
|
283
|
+
events: facts.events.map((event) => event.name),
|
|
284
|
+
sync: facts.syncPairs,
|
|
285
|
+
settings: this.#frameSettings(index),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
/** Create the preview frame(s) and mount the current state; replaces earlier frames. */
|
|
289
|
+
#restart() {
|
|
290
|
+
this.#stop();
|
|
291
|
+
const page = this.page;
|
|
292
|
+
if (!page || !this.isConnected)
|
|
293
|
+
return;
|
|
294
|
+
const registration = registrationOf(page);
|
|
295
|
+
// Without a registration entry the preview item itself says what is missing.
|
|
296
|
+
if (registration === undefined) {
|
|
297
|
+
if (!this.#status.peek().keep)
|
|
298
|
+
this.#status.value = { text: '', alert: false };
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!this.frameUrl) {
|
|
302
|
+
this.#status.value = {
|
|
303
|
+
text: 'Preview unavailable: no preview frame document is configured.',
|
|
304
|
+
alert: false,
|
|
305
|
+
};
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const scope = createScope({ name: `workbench-detail:${page.tag}` });
|
|
309
|
+
this.#previewScope = scope;
|
|
310
|
+
const count = this.#settings.peek().pair ? 2 : 1;
|
|
311
|
+
try {
|
|
312
|
+
for (let i = 0; i < count; i++) {
|
|
313
|
+
const preview = createIsolatedPreview({
|
|
314
|
+
document: this.ownerDocument,
|
|
315
|
+
container: this.#hosts[i],
|
|
316
|
+
frameUrl: this.frameUrl,
|
|
317
|
+
title: `${page.tag} preview${count === 2 ? (i === 0 ? ' (light)' : ' (dark)') : ''}`,
|
|
318
|
+
onMessage: (notification) => this.#onMessage(notification, i),
|
|
319
|
+
});
|
|
320
|
+
preview.iframe.style.blockSize = '14rem';
|
|
321
|
+
scope.add(() => preview.dispose(), 'isolated preview');
|
|
322
|
+
this.#previews.push(preview);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
this.#fail(error);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
this.#mountAll(scope, page, registration);
|
|
330
|
+
}
|
|
331
|
+
#mountAll(scope, page, registration) {
|
|
332
|
+
// Each preview handles requests in order, so changes made from now on follow this mount.
|
|
333
|
+
void Promise.all(this.#previews.map((preview, i) => preview.request('mount', this.#mountPayload(page, registration, i)))).then(() => {
|
|
334
|
+
if (!scope.disposed && !this.#status.peek().alert && !this.#status.peek().keep)
|
|
335
|
+
this.#status.value = { text: '', alert: false };
|
|
336
|
+
}, (error) => {
|
|
337
|
+
if (!scope.disposed)
|
|
338
|
+
this.#fail(error);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
#fail(error) {
|
|
342
|
+
// A request for a frame that was replaced (Reset) or removed has nothing left to report.
|
|
343
|
+
if (error instanceof PreviewFrameError &&
|
|
344
|
+
(error.code === 'frame.generation.superseded' || error.code === 'frame.disposed'))
|
|
345
|
+
return;
|
|
346
|
+
this.#status.value = { text: `Preview unavailable: ${message(error)}`, alert: true };
|
|
347
|
+
}
|
|
348
|
+
#onMessage(notification, index) {
|
|
349
|
+
const payload = notification.payload;
|
|
350
|
+
if (notification.kind === 'event' && isRecord(payload) && index === 0) {
|
|
351
|
+
const entry = {
|
|
352
|
+
id: ++this.#logId,
|
|
353
|
+
name: String(payload['name']),
|
|
354
|
+
time: typeof payload['time'] === 'number' ? payload['time'] : 0,
|
|
355
|
+
bubbles: payload['bubbles'] === true,
|
|
356
|
+
composed: payload['composed'] === true,
|
|
357
|
+
target: String(payload['target'] ?? ''),
|
|
358
|
+
...(payload['detail'] !== undefined ? { detail: JSON.stringify(payload['detail']) } : {}),
|
|
359
|
+
};
|
|
360
|
+
this.#log.value = [entry, ...this.#log.peek()].slice(0, LOG_LIMIT);
|
|
361
|
+
}
|
|
362
|
+
else if (notification.kind === 'error' &&
|
|
363
|
+
isRecord(payload) &&
|
|
364
|
+
payload['phase'] === 'fixture') {
|
|
365
|
+
this.#status.value = {
|
|
366
|
+
text: `The example's setup did not run (${String(payload['message'])}), so the preview shows the element without it.`,
|
|
367
|
+
alert: false,
|
|
368
|
+
keep: true,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
else if (notification.kind === 'error' && isRecord(payload)) {
|
|
372
|
+
this.#status.value = {
|
|
373
|
+
text: `Preview ${String(payload['phase'])} failed: ${String(payload['message'])}. Use Reset to start a fresh preview.`,
|
|
374
|
+
alert: true,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
else if (notification.kind === 'state' &&
|
|
378
|
+
isRecord(payload) &&
|
|
379
|
+
typeof payload['property'] === 'string' &&
|
|
380
|
+
index === 0) {
|
|
381
|
+
// A declared sync pair: mirror the value without writing it back (no reflection loop).
|
|
382
|
+
const property = payload['property'];
|
|
383
|
+
const value = payload['value'];
|
|
384
|
+
const state = this.#state.peek();
|
|
385
|
+
this.#state.value = { ...state, properties: { ...state.properties, [property]: value } };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** Send one request to every live preview. */
|
|
389
|
+
#send(kind, payload) {
|
|
390
|
+
for (const preview of this.#previews)
|
|
391
|
+
void preview.request(kind, payload).catch((error) => this.#fail(error));
|
|
392
|
+
}
|
|
393
|
+
#setError(id, text) {
|
|
394
|
+
const errors = { ...this.#errors.peek() };
|
|
395
|
+
if (text)
|
|
396
|
+
errors[id] = text;
|
|
397
|
+
else
|
|
398
|
+
delete errors[id];
|
|
399
|
+
this.#errors.value = errors;
|
|
400
|
+
}
|
|
401
|
+
/** Why a typed value is not written (a number field's text that is not a number yet). */
|
|
402
|
+
#textProblem(number, mode, text) {
|
|
403
|
+
if (!number)
|
|
404
|
+
return undefined;
|
|
405
|
+
if (mode === 'attribute')
|
|
406
|
+
return text !== '' && !Number.isFinite(Number(text))
|
|
407
|
+
? 'Not written: not a number.'
|
|
408
|
+
: undefined;
|
|
409
|
+
return text.trim() === '' || !Number.isFinite(Number(text))
|
|
410
|
+
? 'Not written: enter a number, or use Unset.'
|
|
411
|
+
: undefined;
|
|
412
|
+
}
|
|
413
|
+
#writeAttribute(row, value) {
|
|
414
|
+
const name = row.attribute;
|
|
415
|
+
const state = this.#state.peek();
|
|
416
|
+
const attributes = { ...state.attributes };
|
|
417
|
+
if (value === null)
|
|
418
|
+
delete attributes[name];
|
|
419
|
+
else
|
|
420
|
+
attributes[name] = value;
|
|
421
|
+
this.#state.value = { ...state, attributes };
|
|
422
|
+
this.#setError(row.id, undefined);
|
|
423
|
+
this.#send('set', { attribute: name, attributeValue: value });
|
|
424
|
+
}
|
|
425
|
+
#writeProperty(row, value) {
|
|
426
|
+
const name = row.property;
|
|
427
|
+
const state = this.#state.peek();
|
|
428
|
+
this.#state.value = { ...state, properties: { ...state.properties, [name]: value } };
|
|
429
|
+
this.#setError(row.id, undefined);
|
|
430
|
+
this.#send('set', { property: name, value });
|
|
431
|
+
}
|
|
432
|
+
/** Unset a property assignment: the frame cannot un-assign, so a clean instance is mounted. */
|
|
433
|
+
#unsetProperty(row) {
|
|
434
|
+
const name = row.property;
|
|
435
|
+
const state = this.#state.peek();
|
|
436
|
+
if (!(name in state.properties))
|
|
437
|
+
return;
|
|
438
|
+
const properties = { ...state.properties };
|
|
439
|
+
delete properties[name];
|
|
440
|
+
this.#state.value = { ...state, properties };
|
|
441
|
+
this.#setError(row.id, undefined);
|
|
442
|
+
this.#remount('Unset recreated the instance without this assignment.');
|
|
443
|
+
}
|
|
444
|
+
#writeSlot(name, text) {
|
|
445
|
+
const state = this.#state.peek();
|
|
446
|
+
this.#state.value = { ...state, slots: { ...state.slots, [name]: text } };
|
|
447
|
+
this.#send('slot', { name, text });
|
|
448
|
+
}
|
|
449
|
+
/** A CSS custom property on the element's style; `null` removes it. */
|
|
450
|
+
#writeStyle(name, value) {
|
|
451
|
+
const state = this.#state.peek();
|
|
452
|
+
const styles = { ...state.styles };
|
|
453
|
+
if (value === null)
|
|
454
|
+
delete styles[name];
|
|
455
|
+
else
|
|
456
|
+
styles[name] = value;
|
|
457
|
+
this.#state.value = { ...state, styles };
|
|
458
|
+
this.#send('set', { cssProperty: name, cssValue: value });
|
|
459
|
+
}
|
|
460
|
+
/** Mount the current state again in the existing frames (a clean instance, same realm). */
|
|
461
|
+
#remount(note) {
|
|
462
|
+
const page = this.page;
|
|
463
|
+
const registration = page && registrationOf(page);
|
|
464
|
+
const scope = this.#previewScope;
|
|
465
|
+
if (!page || registration === undefined || !scope)
|
|
466
|
+
return;
|
|
467
|
+
this.#status.value = { text: note ?? '', alert: false, ...(note ? { keep: true } : {}) };
|
|
468
|
+
this.#mountAll(scope, page, registration);
|
|
469
|
+
}
|
|
470
|
+
#applyPreset(entry) {
|
|
471
|
+
if (!entry.state)
|
|
472
|
+
return;
|
|
473
|
+
this.#state.value = entry.state;
|
|
474
|
+
this.#errors.value = {};
|
|
475
|
+
this.#remount();
|
|
476
|
+
}
|
|
477
|
+
/** Reset: fresh frame documents (new generation) with the selected preset's authored values. */
|
|
478
|
+
#reset() {
|
|
479
|
+
const page = this.page;
|
|
480
|
+
if (!page)
|
|
481
|
+
return;
|
|
482
|
+
const preset = presets(page).find((p) => p.name === this.#state.peek().preset);
|
|
483
|
+
this.#state.value = preset?.state ?? initialState(page);
|
|
484
|
+
this.#errors.value = {};
|
|
485
|
+
this.#log.value = [];
|
|
486
|
+
this.#status.value = { text: '', alert: false };
|
|
487
|
+
this.#replace();
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Replace the preview realms with fresh frame documents and mount the current configuration
|
|
491
|
+
* again. The shell calls this when component code changes (a registration cannot be redefined
|
|
492
|
+
* in the same realm).
|
|
493
|
+
*/
|
|
494
|
+
replacePreview() {
|
|
495
|
+
return this.#replace();
|
|
496
|
+
}
|
|
497
|
+
#replace() {
|
|
498
|
+
const page = this.page;
|
|
499
|
+
const registration = page && registrationOf(page);
|
|
500
|
+
const scope = this.#previewScope;
|
|
501
|
+
if (!page || registration === undefined || !scope) {
|
|
502
|
+
this.#restart();
|
|
503
|
+
return Promise.resolve();
|
|
504
|
+
}
|
|
505
|
+
return Promise.all(this.#previews.map((preview) => preview.reload())).then(() => this.#mountAll(scope, page, registration), (error) => {
|
|
506
|
+
if (!scope.disposed)
|
|
507
|
+
this.#fail(error);
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
#applySettings(next) {
|
|
511
|
+
const pairChanged = next.pair !== this.#settings.peek().pair;
|
|
512
|
+
this.#settings.value = next;
|
|
513
|
+
if (pairChanged) {
|
|
514
|
+
this.#restart();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this.#previews.forEach((preview, i) => {
|
|
518
|
+
void preview
|
|
519
|
+
.request('settings', this.#frameSettings(i))
|
|
520
|
+
.catch((error) => this.#fail(error));
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
#shareLink(page) {
|
|
524
|
+
const settings = this.#settings.peek();
|
|
525
|
+
const width = settings.width === 'fill'
|
|
526
|
+
? 'fill'
|
|
527
|
+
: settings.width === 'custom'
|
|
528
|
+
? settings.customWidth
|
|
529
|
+
: WIDTHS[settings.width];
|
|
530
|
+
// Light and dark side by side is a view of the page, not a preview setting to share.
|
|
531
|
+
const preview = {
|
|
532
|
+
...(settings.mode === 'site' || settings.pair
|
|
533
|
+
? { sameAsSite: true }
|
|
534
|
+
: { mode: settings.mode }),
|
|
535
|
+
direction: settings.direction,
|
|
536
|
+
width,
|
|
537
|
+
};
|
|
538
|
+
const encoded = encodeShare(sharePayload(page.tag, this.#state.peek(), preview));
|
|
539
|
+
const route = this.index?.registry.pages.find((p) => p.id === page.id)?.route ?? `/${page.id}`;
|
|
540
|
+
const url = new URL(this.href(route), this.ownerDocument.baseURI);
|
|
541
|
+
// Hash routing keeps the query inside the fragment, where the router reads it.
|
|
542
|
+
if (url.hash.startsWith('#/')) {
|
|
543
|
+
const [path = '', query = ''] = url.hash.slice(1).split('?');
|
|
544
|
+
const params = new URLSearchParams(query);
|
|
545
|
+
params.set(SHARE_PARAM, encoded);
|
|
546
|
+
url.hash = `${path}?${params}`;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
url.searchParams.set(SHARE_PARAM, encoded);
|
|
550
|
+
url.hash = 'interactive-example';
|
|
551
|
+
}
|
|
552
|
+
return url.href;
|
|
553
|
+
}
|
|
554
|
+
#copyLink(page) {
|
|
555
|
+
const link = this.#shareLink(page);
|
|
556
|
+
this.#link.value = link;
|
|
557
|
+
const clipboard = this.ownerDocument.defaultView?.navigator.clipboard;
|
|
558
|
+
if (!clipboard)
|
|
559
|
+
return;
|
|
560
|
+
clipboard.writeText(link).then(() => (this.#status.value = { text: 'Example link copied.', alert: false, keep: true }), () => undefined);
|
|
561
|
+
}
|
|
562
|
+
#copyCode() {
|
|
563
|
+
const code = this.shadowRoot?.querySelector('.wb-snippet')?.textContent ?? '';
|
|
564
|
+
this.ownerDocument.defaultView?.navigator.clipboard?.writeText(code).then(() => (this.#status.value = { text: 'Code copied.', alert: false, keep: true }), () => undefined);
|
|
565
|
+
}
|
|
566
|
+
// ---- Rendering ----
|
|
567
|
+
render() {
|
|
568
|
+
const page = this.page;
|
|
569
|
+
const index = this.index;
|
|
570
|
+
if (!page || !index)
|
|
571
|
+
return html `<div class="wb-content"></div>`;
|
|
572
|
+
const entry = index.registry.pages.find((p) => p.id === page.id);
|
|
573
|
+
const reference = this.layout === 'reference';
|
|
574
|
+
const facts = componentFacts(page);
|
|
575
|
+
const coverage = pageCoverage(page);
|
|
576
|
+
const at = covered(coverage, reference);
|
|
577
|
+
// The reference layout leaves out a section with nothing to show; Workbench labels it.
|
|
578
|
+
const sections = (entry?.sections ?? []).filter((s) => s.visible &&
|
|
579
|
+
(!reference ||
|
|
580
|
+
coverage.some((item) => item.section === s.id && item.state === 'documented') ||
|
|
581
|
+
(s.id === 'api' && facts.cssProperties.length > 0)));
|
|
582
|
+
const links = reference && !this.sections
|
|
583
|
+
? PAGE_LINKS.filter(([id]) => sections.some((s) => s.id === id))
|
|
584
|
+
: [];
|
|
585
|
+
const current = this.#current.value || links[0]?.[0];
|
|
586
|
+
const shown = this.sections;
|
|
587
|
+
// Sections are built eagerly (not with repeat()), so the signals they read are tracked.
|
|
588
|
+
return html `<div class="wb-content">
|
|
589
|
+
${links.length
|
|
590
|
+
? html `<nav class="wb-page-nav" aria-label="Page sections">
|
|
591
|
+
${links.map(([id, label]) => html `<a
|
|
592
|
+
href=${`#${id}`}
|
|
593
|
+
aria-current=${current === id ? 'true' : null}
|
|
594
|
+
@click=${(event) => this.#jump(event, id)}
|
|
595
|
+
>${label}</a
|
|
596
|
+
>`)}
|
|
597
|
+
</nav>`
|
|
598
|
+
: nothing}
|
|
599
|
+
${sections.map((section) => html `<section
|
|
600
|
+
id=${section.id}
|
|
601
|
+
aria-labelledby=${`${section.id}-title`}
|
|
602
|
+
?hidden=${!!shown && !shown.includes(section.id)}
|
|
603
|
+
>
|
|
604
|
+
<h2 id=${`${section.id}-title`} tabindex="-1">${section.title}</h2>
|
|
605
|
+
${this.#section(section.id, page, facts, at, sections)}
|
|
606
|
+
</section>`)}
|
|
607
|
+
</div>`;
|
|
608
|
+
}
|
|
609
|
+
#section(id, page, facts, at, sections) {
|
|
610
|
+
switch (id) {
|
|
611
|
+
case 'overview':
|
|
612
|
+
return this.#overview(page, at);
|
|
613
|
+
case 'interactive-example':
|
|
614
|
+
return this.#interactive(page, facts, at);
|
|
615
|
+
case 'api':
|
|
616
|
+
return this.#reference(facts, at, sections.some((s) => s.id === 'interactive-example'));
|
|
617
|
+
case 'examples':
|
|
618
|
+
return at('examples/examples', () => this.#examples(page));
|
|
619
|
+
case 'tokens':
|
|
620
|
+
return this.#tokens(page, at);
|
|
621
|
+
case 'behavior-and-accessibility':
|
|
622
|
+
return this.#behavior(page, at);
|
|
623
|
+
case 'guidance':
|
|
624
|
+
return this.#guidance(page, at);
|
|
625
|
+
case 'source':
|
|
626
|
+
return this.#source(page, at);
|
|
627
|
+
case 'known-limitations':
|
|
628
|
+
return at('known-limitations/limitations', () => html `<div class="wb-callout">
|
|
629
|
+
${bullets(authoredValues(page.enrichment?.['limitations']))}
|
|
630
|
+
</div>`);
|
|
631
|
+
default:
|
|
632
|
+
return notDocumented(`pages["${page.id}"].sections["${id}"] in oddkin.docs.ts`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
#overview(page, at) {
|
|
636
|
+
const description = typeof page.declaration['description'] === 'string' ? page.declaration['description'] : '';
|
|
637
|
+
return at('overview/description', () => description
|
|
638
|
+
.split(/\n\s*\n/)
|
|
639
|
+
.map((paragraph) => html `<p>${inline(paragraph.replace(/\s*\n\s*/g, ' '))}</p>`));
|
|
640
|
+
}
|
|
641
|
+
/** Intended use and explained mistakes, each paired with the example to use instead. */
|
|
642
|
+
#guidance(page, at) {
|
|
643
|
+
const { useFor, notFor } = intendedUse(page);
|
|
644
|
+
const authored = page.authored?.guidance ?? '';
|
|
645
|
+
const show = (entry, label) => entry.state
|
|
646
|
+
? html `<button type="button" @click=${() => this.#applyPreset(entry)}>${label}</button>`
|
|
647
|
+
: nothing;
|
|
648
|
+
return html `${at('guidance/authored', () => authored.split(/\n\s*\n/).map((paragraph) => html `<p>${inline(paragraph)}</p>`))}
|
|
649
|
+
<h3>Use it for</h3>
|
|
650
|
+
${at('guidance/use-for', () => bullets(useFor))}
|
|
651
|
+
<h3>Not for</h3>
|
|
652
|
+
${at('guidance/not-for', () => bullets(notFor))}
|
|
653
|
+
<h3>Correct and incorrect usage</h3>
|
|
654
|
+
${at('guidance/usage-pairs', () => html `<ul class="wb-list wb-pairs">
|
|
655
|
+
${usagePairs(page).map(({ incorrect, correct }) => html `<li data-pair=${incorrect.id}>
|
|
656
|
+
<p><strong>Incorrect:</strong> ${incorrect.label} <code>${incorrect.id}</code></p>
|
|
657
|
+
${incorrect.incorrect?.explanation
|
|
658
|
+
? html `<p>${inline(incorrect.incorrect.explanation)}</p>`
|
|
659
|
+
: notDocumented('incorrect.explanation in defineExamples', 'Explanation')}
|
|
660
|
+
<p>
|
|
661
|
+
<strong>Correct:</strong>
|
|
662
|
+
${correct
|
|
663
|
+
? html `${correct.label} <code>${correct.id}</code>`
|
|
664
|
+
: html `<span class="wb-not-documented">Not documented</span>`}
|
|
665
|
+
</p>
|
|
666
|
+
${show(incorrect, 'Show the incorrect usage in the preview')}
|
|
667
|
+
${correct ? show(correct, 'Show the correct usage in the preview') : nothing}
|
|
668
|
+
</li>`)}
|
|
669
|
+
</ul>`)}`;
|
|
670
|
+
}
|
|
671
|
+
#interactive(page, facts, at) {
|
|
672
|
+
const state = this.#state.value;
|
|
673
|
+
const settings = this.#settings.value;
|
|
674
|
+
const status = this.#status.value;
|
|
675
|
+
const entries = presets(page).filter((p) => p.state);
|
|
676
|
+
const link = this.#link.value;
|
|
677
|
+
const frameSettings = settings.pair ? {} : this.#frameSettings(0);
|
|
678
|
+
const update = (patch) => this.#applySettings({ ...settings, ...patch });
|
|
679
|
+
const code = this.#code.value;
|
|
680
|
+
const tools = this.layout === 'workbench';
|
|
681
|
+
return html `<div class="wb-example">
|
|
682
|
+
<div class="wb-example-bar">
|
|
683
|
+
<p class="wb-example-title">Interactive example · ${page.tag}</p>
|
|
684
|
+
${tools
|
|
685
|
+
? at('interactive-example/presets', () => html `<label
|
|
686
|
+
>Preset
|
|
687
|
+
<select
|
|
688
|
+
.value=${state.preset}
|
|
689
|
+
@change=${(event) => {
|
|
690
|
+
const name = event.target.value;
|
|
691
|
+
const entry = entries.find((p) => p.name === name);
|
|
692
|
+
if (entry)
|
|
693
|
+
this.#applyPreset(entry);
|
|
694
|
+
}}
|
|
695
|
+
>
|
|
696
|
+
${entries.map((p) => html `<option value=${p.name} ?selected=${p.name === state.preset}>
|
|
697
|
+
${p.label}${p.incorrect ? ' (incorrect usage)' : ''}
|
|
698
|
+
</option>`)}
|
|
699
|
+
</select></label
|
|
700
|
+
>`)
|
|
701
|
+
: nothing}
|
|
702
|
+
<button
|
|
703
|
+
type="button"
|
|
704
|
+
aria-expanded=${code ? 'true' : 'false'}
|
|
705
|
+
aria-controls="wb-example-code"
|
|
706
|
+
@click=${() => (this.#code.value = !code)}
|
|
707
|
+
>
|
|
708
|
+
${code ? 'Hide code' : 'See code'}
|
|
709
|
+
</button>
|
|
710
|
+
${tools
|
|
711
|
+
? html `<button type="button" @click=${() => this.#copyLink(page)}>
|
|
712
|
+
Copy example link
|
|
713
|
+
</button>`
|
|
714
|
+
: nothing}
|
|
715
|
+
<button type="button" class="wb-quiet" @click=${() => this.#reset()}>Reset</button>
|
|
716
|
+
</div>
|
|
717
|
+
${link
|
|
718
|
+
? html `<label class="wb-share"
|
|
719
|
+
>Example link <input type="url" readonly .value=${link} size="40"
|
|
720
|
+
/></label>`
|
|
721
|
+
: nothing}
|
|
722
|
+
<div class="wb-example-stage">
|
|
723
|
+
${at('interactive-example/preview', () => html `<div class=${settings.pair ? 'wb-previews wb-pair' : 'wb-previews'}>
|
|
724
|
+
<div class="wb-preview">
|
|
725
|
+
${settings.pair ? html `<p>Light</p>` : nothing}${this.#hosts[0]}
|
|
726
|
+
</div>
|
|
727
|
+
${settings.pair
|
|
728
|
+
? html `<div class="wb-preview">
|
|
729
|
+
<p>Dark</p>
|
|
730
|
+
${this.#hosts[1]}
|
|
731
|
+
</div>`
|
|
732
|
+
: nothing}
|
|
733
|
+
</div>
|
|
734
|
+
<p class="wb-muted wb-example-caption">Changes appear here as you edit.</p>`)}
|
|
735
|
+
<p class="wb-status" role=${status.alert ? 'alert' : 'status'}>${status.text}</p>
|
|
736
|
+
</div>
|
|
737
|
+
${tools ? this.#settingsBar(settings, update) : nothing}
|
|
738
|
+
<div class="wb-example-controls">
|
|
739
|
+
<div class="wb-controls-head">
|
|
740
|
+
<h3>Controls and API</h3>
|
|
741
|
+
<p class="wb-muted">Scroll the table; the preview stays in view.</p>
|
|
742
|
+
</div>
|
|
743
|
+
<div class="wb-controls-scroll" role="region" aria-label="Controls and API" tabindex="0">
|
|
744
|
+
${at('api/properties', () => this.#apiTable(facts))}
|
|
745
|
+
${at('api/slots', () => this.#slots(facts))}
|
|
746
|
+
${at('api/css-properties', () => this.#cssTable(facts))}
|
|
747
|
+
</div>
|
|
748
|
+
</div>
|
|
749
|
+
<div class="wb-example-code" id="wb-example-code" ?hidden=${!code}>
|
|
750
|
+
<div class="wb-code-head">
|
|
751
|
+
<p class="wb-example-title">Generated HTML · follows the current controls</p>
|
|
752
|
+
<button type="button" @click=${() => this.#copyCode()}>Copy code</button>
|
|
753
|
+
</div>
|
|
754
|
+
<pre
|
|
755
|
+
class="wb-code wb-snippet"
|
|
756
|
+
tabindex="0"
|
|
757
|
+
><code>${snippet(page, state, { settings: frameSettings })}</code></pre>
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
${tools
|
|
761
|
+
? html `<h3>Event log</h3>
|
|
762
|
+
${this.#eventLog(facts)}`
|
|
763
|
+
: nothing}`;
|
|
764
|
+
}
|
|
765
|
+
/** Workbench's preview settings: mode, light and dark together, direction, and width. */
|
|
766
|
+
#settingsBar(settings, update) {
|
|
767
|
+
return html `<div class="wb-example-settings">
|
|
768
|
+
<div class="wb-toolbar">
|
|
769
|
+
<label
|
|
770
|
+
>Mode
|
|
771
|
+
<select
|
|
772
|
+
.value=${settings.mode}
|
|
773
|
+
?disabled=${settings.pair}
|
|
774
|
+
@change=${(event) => update({ mode: event.target.value })}
|
|
775
|
+
>
|
|
776
|
+
<option value="site" ?selected=${settings.mode === 'site'}>Same as site</option>
|
|
777
|
+
<option value="light" ?selected=${settings.mode === 'light'}>Light</option>
|
|
778
|
+
<option value="dark" ?selected=${settings.mode === 'dark'}>Dark</option>
|
|
779
|
+
</select></label
|
|
780
|
+
>
|
|
781
|
+
<label
|
|
782
|
+
><span>Light and dark side by side</span>
|
|
783
|
+
<input
|
|
784
|
+
type="checkbox"
|
|
785
|
+
.checked=${settings.pair}
|
|
786
|
+
@change=${(event) => update({ pair: event.target.checked })}
|
|
787
|
+
/></label>
|
|
788
|
+
<label
|
|
789
|
+
>Direction
|
|
790
|
+
<select
|
|
791
|
+
.value=${settings.direction}
|
|
792
|
+
@change=${(event) => update({
|
|
793
|
+
direction: event.target.value,
|
|
794
|
+
})}
|
|
795
|
+
>
|
|
796
|
+
<option value="ltr" ?selected=${settings.direction === 'ltr'}>Left to right</option>
|
|
797
|
+
<option value="rtl" ?selected=${settings.direction === 'rtl'}>Right to left</option>
|
|
798
|
+
</select></label
|
|
799
|
+
>
|
|
800
|
+
<label
|
|
801
|
+
>Width
|
|
802
|
+
<select
|
|
803
|
+
.value=${settings.width}
|
|
804
|
+
@change=${(event) => update({ width: event.target.value })}
|
|
805
|
+
>
|
|
806
|
+
<option value="fill" ?selected=${settings.width === 'fill'}>Fill</option>
|
|
807
|
+
<option value="narrow" ?selected=${settings.width === 'narrow'}>Narrow (320px)</option>
|
|
808
|
+
<option value="medium" ?selected=${settings.width === 'medium'}>Medium (640px)</option>
|
|
809
|
+
<option value="wide" ?selected=${settings.width === 'wide'}>Wide (960px)</option>
|
|
810
|
+
<option value="custom" ?selected=${settings.width === 'custom'}>Custom</option>
|
|
811
|
+
</select></label
|
|
812
|
+
>
|
|
813
|
+
${settings.width === 'custom'
|
|
814
|
+
? html `<label
|
|
815
|
+
>Width in px
|
|
816
|
+
<input
|
|
817
|
+
type="number"
|
|
818
|
+
min=${String(MIN_SHARE_WIDTH)}
|
|
819
|
+
max=${String(MAX_SHARE_WIDTH)}
|
|
820
|
+
step="1"
|
|
821
|
+
.value=${String(settings.customWidth)}
|
|
822
|
+
@change=${(event) => {
|
|
823
|
+
const value = Number(event.target.value);
|
|
824
|
+
if (Number.isInteger(value) &&
|
|
825
|
+
value >= MIN_SHARE_WIDTH &&
|
|
826
|
+
value <= MAX_SHARE_WIDTH)
|
|
827
|
+
update({ customWidth: value });
|
|
828
|
+
}}
|
|
829
|
+
/></label>`
|
|
830
|
+
: nothing}
|
|
831
|
+
</div>
|
|
832
|
+
<p class="wb-muted wb-settings-summary">
|
|
833
|
+
Preview only:
|
|
834
|
+
${settings.pair ? 'light and dark' : settings.mode === 'site' ? 'same mode as the site' : settings.mode}
|
|
835
|
+
· ${settings.direction === 'rtl' ? 'right to left' : 'left to right'} ·
|
|
836
|
+
${settings.width === 'fill'
|
|
837
|
+
? 'fills the column'
|
|
838
|
+
: `${settings.width === 'custom' ? settings.customWidth : WIDTHS[settings.width]}px container`}
|
|
839
|
+
</p>
|
|
840
|
+
</div>`;
|
|
841
|
+
}
|
|
842
|
+
#eventLog(facts) {
|
|
843
|
+
if (!facts.events.length)
|
|
844
|
+
return html `<p class="wb-muted wb-event-log">No events are declared, so none are logged.</p>`;
|
|
845
|
+
const log = this.#log.value;
|
|
846
|
+
return html `<div class="wb-event-log">
|
|
847
|
+
<p class="wb-muted">
|
|
848
|
+
Logging ${facts.events.map((e, i) => html `${i ? ', ' : ''}<code>${e.name}</code>`)} (latest
|
|
849
|
+
first, at most ${LOG_LIMIT}).
|
|
850
|
+
<button type="button" ?disabled=${!log.length} @click=${() => (this.#log.value = [])}>
|
|
851
|
+
Clear
|
|
852
|
+
</button>
|
|
853
|
+
</p>
|
|
854
|
+
${log.length
|
|
855
|
+
? html `<ol class="wb-log" aria-label="Events">
|
|
856
|
+
${repeat(log, (entry) => entry.id, (entry) => html `<li>
|
|
857
|
+
<code>${entry.name}</code> at ${entry.time.toFixed(0)} ms on
|
|
858
|
+
<code>${entry.target}</code> · ${entry.bubbles ? 'bubbles' : 'does not bubble'}
|
|
859
|
+
· ${entry.composed ? 'composed' : 'not composed'}
|
|
860
|
+
${entry.detail !== undefined ? html `<br /><code>detail: ${entry.detail}</code>` : nothing}
|
|
861
|
+
</li>`)}
|
|
862
|
+
</ol>`
|
|
863
|
+
: html `<p class="wb-muted">No events yet.</p>`}
|
|
864
|
+
</div>`;
|
|
865
|
+
}
|
|
866
|
+
/** Contracts beside the controls (Figma 232:9895): events, parts, methods, states, and styling hooks. */
|
|
867
|
+
#reference(facts, at, inExample) {
|
|
868
|
+
const reference = this.layout === 'reference';
|
|
869
|
+
const card = (id, title, entries, name = (n) => n) => reference && !entries.length
|
|
870
|
+
? nothing
|
|
871
|
+
: html `<div class="wb-box" data-card=${id}>
|
|
872
|
+
<h3>${title}</h3>
|
|
873
|
+
${at(`api/${id}`, () => html `<ul class="wb-plain">
|
|
874
|
+
${entries.map((entry) => html `<li>
|
|
875
|
+
<code>${name(entry.name)}</code
|
|
876
|
+
>${entry.description ? html ` — ${inline(entry.description)}` : nothing}
|
|
877
|
+
</li>`)}
|
|
878
|
+
</ul>`)}
|
|
879
|
+
</div>`;
|
|
880
|
+
return html `${inExample
|
|
881
|
+
? html `<p class="wb-lede">
|
|
882
|
+
Contracts that complement the properties and slots table above.
|
|
883
|
+
</p>`
|
|
884
|
+
: html `${at('api/properties', () => this.#apiTable(facts))}
|
|
885
|
+
${at('api/slots', () => this.#slots(facts))}
|
|
886
|
+
${at('api/css-properties', () => this.#cssTable(facts))}`}
|
|
887
|
+
<div class="wb-boxes">
|
|
888
|
+
${card('events', 'Events', facts.events)} ${card('parts', 'CSS parts', facts.parts)}
|
|
889
|
+
${card('methods', 'Methods', facts.methods)}
|
|
890
|
+
${card('css-states', 'Custom states', facts.cssStates, (n) => `:state(${n})`)}
|
|
891
|
+
</div>
|
|
892
|
+
${facts.cssProperties.length
|
|
893
|
+
? html `<div class="wb-box wb-sunken" data-card="css-properties">
|
|
894
|
+
<h3>CSS custom properties</h3>
|
|
895
|
+
<ul class="wb-plain wb-names">
|
|
896
|
+
${facts.cssProperties.map((entry) => html `<li><code>${entry.name}</code></li>`)}
|
|
897
|
+
</ul>
|
|
898
|
+
</div>`
|
|
899
|
+
: nothing}`;
|
|
900
|
+
}
|
|
901
|
+
#apiTable(facts) {
|
|
902
|
+
return this.#group('api', 'Editing a value updates the preview: one write per change, as a property or as an attribute. Reflected attributes are not written back.', ['Properties', 'Types', 'Default', 'Attribute', 'Reflected', 'Control / current value'], facts.rows.map((row) => this.#apiRow(row)));
|
|
903
|
+
}
|
|
904
|
+
#apiRow(row) {
|
|
905
|
+
return html `<tr data-row=${row.id}>
|
|
906
|
+
${named(row.property ?? row.attribute ?? '', row.description)}
|
|
907
|
+
<td data-label="Types">
|
|
908
|
+
${row.values ? row.values.join(', ') : row.type}${row.values && row.nullable ? ' · nullable' : ''}
|
|
909
|
+
</td>
|
|
910
|
+
<td data-label="Default">${row.default ? unquote(row.default) : 'Unknown'}</td>
|
|
911
|
+
<td data-label="Attribute">
|
|
912
|
+
${row.attribute ? (row.property ? row.attribute : `${row.attribute} (attribute only)`) : row.propertyOnly ? 'Property only' : 'None'}
|
|
913
|
+
</td>
|
|
914
|
+
<td data-label="Reflected">
|
|
915
|
+
${row.property && row.attribute ? (row.reflects ? 'Yes' : 'No') : '—'}
|
|
916
|
+
</td>
|
|
917
|
+
<td data-label="Control">${this.#control(row)}</td>
|
|
918
|
+
</tr>`;
|
|
919
|
+
}
|
|
920
|
+
#control(row) {
|
|
921
|
+
const name = row.property ?? row.attribute ?? '';
|
|
922
|
+
if (row.control === 'readonly')
|
|
923
|
+
return html `<span class="wb-muted">Read-only; inspected, never edited.</span>`;
|
|
924
|
+
if (row.control === 'fixture')
|
|
925
|
+
return html `<span class="wb-muted"
|
|
926
|
+
>Requires fixture: <code>${row.type}</code> is not editable generically.</span
|
|
927
|
+
>`;
|
|
928
|
+
const state = this.#state.value;
|
|
929
|
+
const modes = this.#modes.value;
|
|
930
|
+
const mode = modes[row.id] ?? defaultWriteMode(row);
|
|
931
|
+
const error = this.#errors.value[row.id];
|
|
932
|
+
const label = `${name} (${mode})`;
|
|
933
|
+
const attr = row.attribute;
|
|
934
|
+
const prop = row.property;
|
|
935
|
+
const attrValue = attr ? state.attributes[attr] : undefined;
|
|
936
|
+
const propSet = prop !== undefined && prop in state.properties;
|
|
937
|
+
const propValue = prop !== undefined ? state.properties[prop] : undefined;
|
|
938
|
+
const modeSwitch = this.layout === 'workbench' &&
|
|
939
|
+
row.property &&
|
|
940
|
+
row.attribute &&
|
|
941
|
+
!row.propertyOnly &&
|
|
942
|
+
row.control !== 'json'
|
|
943
|
+
? html `<select
|
|
944
|
+
aria-label=${`Write ${name} as`}
|
|
945
|
+
.value=${mode}
|
|
946
|
+
@change=${(event) => {
|
|
947
|
+
this.#modes.value = {
|
|
948
|
+
...this.#modes.peek(),
|
|
949
|
+
[row.id]: event.target.value,
|
|
950
|
+
};
|
|
951
|
+
}}
|
|
952
|
+
>
|
|
953
|
+
<option value="attribute" ?selected=${mode === 'attribute'}>attribute</option>
|
|
954
|
+
<option value="property" ?selected=${mode === 'property'}>property</option>
|
|
955
|
+
</select>`
|
|
956
|
+
: nothing;
|
|
957
|
+
let control;
|
|
958
|
+
if (row.control === 'boolean') {
|
|
959
|
+
const checked = mode === 'attribute' ? attrValue !== undefined : propValue === true;
|
|
960
|
+
control = html `<input
|
|
961
|
+
type="checkbox"
|
|
962
|
+
role="switch"
|
|
963
|
+
class="wb-switch"
|
|
964
|
+
aria-label=${label}
|
|
965
|
+
.checked=${checked}
|
|
966
|
+
@change=${(event) => {
|
|
967
|
+
const on = event.target.checked;
|
|
968
|
+
if (mode === 'attribute')
|
|
969
|
+
this.#writeAttribute(row, on ? '' : null);
|
|
970
|
+
else
|
|
971
|
+
this.#writeProperty(row, on);
|
|
972
|
+
}}
|
|
973
|
+
/>
|
|
974
|
+
<span class="wb-muted"
|
|
975
|
+
>${mode === 'attribute'
|
|
976
|
+
? attrValue !== undefined
|
|
977
|
+
? 'present'
|
|
978
|
+
: 'absent'
|
|
979
|
+
: propSet
|
|
980
|
+
? String(propValue)
|
|
981
|
+
: 'unset'}</span
|
|
982
|
+
>`;
|
|
983
|
+
}
|
|
984
|
+
else if (row.control === 'enum') {
|
|
985
|
+
const current = mode === 'attribute'
|
|
986
|
+
? (attrValue ?? '')
|
|
987
|
+
: propSet
|
|
988
|
+
? propValue === null
|
|
989
|
+
? '\u0000null'
|
|
990
|
+
: String(propValue)
|
|
991
|
+
: '';
|
|
992
|
+
const write = (value) => {
|
|
993
|
+
if (mode === 'attribute')
|
|
994
|
+
this.#writeAttribute(row, value === '' ? null : value);
|
|
995
|
+
else if (value === '')
|
|
996
|
+
this.#unsetProperty(row);
|
|
997
|
+
else
|
|
998
|
+
this.#writeProperty(row, value === '\u0000null' ? null : value);
|
|
999
|
+
};
|
|
1000
|
+
const values = row.values ?? [];
|
|
1001
|
+
control = html `<select
|
|
1002
|
+
aria-label=${label}
|
|
1003
|
+
.value=${current}
|
|
1004
|
+
@change=${(event) => write(event.target.value)}
|
|
1005
|
+
>
|
|
1006
|
+
<option value="" ?selected=${current === ''}>(unset)</option>
|
|
1007
|
+
${values.map((value) => html `<option value=${value} ?selected=${current === value}>${value}</option>`)}
|
|
1008
|
+
${mode === 'property' && row.nullable
|
|
1009
|
+
? html `<option value=${'\u0000null'} ?selected=${current === '\u0000null'}>
|
|
1010
|
+
null
|
|
1011
|
+
</option>`
|
|
1012
|
+
: nothing}
|
|
1013
|
+
</select>`;
|
|
1014
|
+
}
|
|
1015
|
+
else if (row.control === 'json') {
|
|
1016
|
+
control = html `<textarea
|
|
1017
|
+
aria-label=${label}
|
|
1018
|
+
rows="3"
|
|
1019
|
+
.value=${propSet ? JSON.stringify(propValue) : ''}
|
|
1020
|
+
placeholder="unset"
|
|
1021
|
+
@change=${(event) => {
|
|
1022
|
+
const text = event.target.value.trim();
|
|
1023
|
+
if (!text) {
|
|
1024
|
+
this.#unsetProperty(row);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
const value = JSON.parse(text);
|
|
1029
|
+
if (!Array.isArray(value) && !(value === null && row.nullable))
|
|
1030
|
+
throw new Error('expected a JSON array');
|
|
1031
|
+
this.#writeProperty(row, value);
|
|
1032
|
+
}
|
|
1033
|
+
catch (cause) {
|
|
1034
|
+
this.#setError(row.id, `Not written: ${message(cause)}.`);
|
|
1035
|
+
}
|
|
1036
|
+
}}
|
|
1037
|
+
></textarea>`;
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
const number = row.control === 'number';
|
|
1041
|
+
const isSet = mode === 'attribute' ? attrValue !== undefined : propSet;
|
|
1042
|
+
const shown = mode === 'attribute'
|
|
1043
|
+
? (attrValue ?? '')
|
|
1044
|
+
: propSet && propValue !== null
|
|
1045
|
+
? String(propValue)
|
|
1046
|
+
: '';
|
|
1047
|
+
control = html `<input
|
|
1048
|
+
type=${number ? 'number' : 'text'}
|
|
1049
|
+
aria-label=${label}
|
|
1050
|
+
placeholder=${isSet ? '' : 'unset'}
|
|
1051
|
+
.value=${shown}
|
|
1052
|
+
@input=${(event) => {
|
|
1053
|
+
// The preview follows the field as the reader types; an unfinished number waits.
|
|
1054
|
+
const text = event.target.value;
|
|
1055
|
+
if (this.#textProblem(number, mode, text))
|
|
1056
|
+
return;
|
|
1057
|
+
if (mode === 'attribute')
|
|
1058
|
+
this.#writeAttribute(row, text);
|
|
1059
|
+
else
|
|
1060
|
+
this.#writeProperty(row, number ? Number(text) : text);
|
|
1061
|
+
}}
|
|
1062
|
+
@change=${(event) => {
|
|
1063
|
+
const problem = this.#textProblem(number, mode, event.target.value);
|
|
1064
|
+
if (problem)
|
|
1065
|
+
this.#setError(row.id, problem);
|
|
1066
|
+
}}
|
|
1067
|
+
/>
|
|
1068
|
+
<button
|
|
1069
|
+
type="button"
|
|
1070
|
+
?disabled=${!isSet}
|
|
1071
|
+
@click=${() => {
|
|
1072
|
+
if (mode === 'attribute')
|
|
1073
|
+
this.#writeAttribute(row, null);
|
|
1074
|
+
else
|
|
1075
|
+
this.#unsetProperty(row);
|
|
1076
|
+
}}
|
|
1077
|
+
>
|
|
1078
|
+
Unset
|
|
1079
|
+
</button>
|
|
1080
|
+
${mode === 'property' && row.nullable
|
|
1081
|
+
? html `<button
|
|
1082
|
+
type="button"
|
|
1083
|
+
?disabled=${propSet && propValue === null}
|
|
1084
|
+
@click=${() => this.#writeProperty(row, null)}
|
|
1085
|
+
>
|
|
1086
|
+
Set null
|
|
1087
|
+
</button>`
|
|
1088
|
+
: nothing}
|
|
1089
|
+
<span class="wb-muted"
|
|
1090
|
+
>${!isSet
|
|
1091
|
+
? 'unset'
|
|
1092
|
+
: mode === 'property' && propValue === null
|
|
1093
|
+
? 'null'
|
|
1094
|
+
: shown === ''
|
|
1095
|
+
? 'empty string'
|
|
1096
|
+
: ''}</span
|
|
1097
|
+
>`;
|
|
1098
|
+
}
|
|
1099
|
+
return html `<div class="wb-cell-controls">${control} ${modeSwitch}</div>
|
|
1100
|
+
${error ? html `<p class="wb-error" role="alert">${error}</p>` : nothing}`;
|
|
1101
|
+
}
|
|
1102
|
+
/** One group of the controls: its header row names the group and its columns, and stays in view. */
|
|
1103
|
+
#group(id, caption, heads, rows) {
|
|
1104
|
+
return html `<div class="wb-table-wrap">
|
|
1105
|
+
<table class="wb-stack wb-api" data-table=${id}>
|
|
1106
|
+
<caption class="wb-visually-hidden">
|
|
1107
|
+
${caption}
|
|
1108
|
+
</caption>
|
|
1109
|
+
<colgroup>
|
|
1110
|
+
${[0, 1, 2, 3, 4, 5].map(() => html `<col />`)}
|
|
1111
|
+
</colgroup>
|
|
1112
|
+
<thead>
|
|
1113
|
+
<tr>
|
|
1114
|
+
${heads.map((head, i) => html `<th scope="col" colspan=${heads.length === 5 && i === 3 ? '2' : null}>
|
|
1115
|
+
${head}
|
|
1116
|
+
</th>`)}
|
|
1117
|
+
</tr>
|
|
1118
|
+
</thead>
|
|
1119
|
+
<tbody>
|
|
1120
|
+
${rows}
|
|
1121
|
+
</tbody>
|
|
1122
|
+
</table>
|
|
1123
|
+
</div>`;
|
|
1124
|
+
}
|
|
1125
|
+
#slots(facts) {
|
|
1126
|
+
const state = this.#state.value;
|
|
1127
|
+
return this.#group('slots', 'Slots: text content written into the preview.', ['Slots', 'Content type', 'Default', 'Name', 'Position', 'Control / current content'], facts.slots.map((slot) => {
|
|
1128
|
+
const label = slot.name || 'default';
|
|
1129
|
+
return html `<tr data-slot=${slot.name}>
|
|
1130
|
+
${named(label, undefined)}
|
|
1131
|
+
<td data-label="Content type">Text</td>
|
|
1132
|
+
<td data-label="Default">Empty</td>
|
|
1133
|
+
<td data-label="Name">${slot.name || '(none)'}</td>
|
|
1134
|
+
<td data-label="Position">
|
|
1135
|
+
${slot.description ? inline(slot.description) : html `<span class="wb-not-documented">Not documented</span>`}
|
|
1136
|
+
</td>
|
|
1137
|
+
<td data-label="Control">
|
|
1138
|
+
<input
|
|
1139
|
+
type="text"
|
|
1140
|
+
aria-label=${`${label} slot text`}
|
|
1141
|
+
.value=${state.slots[slot.name] ?? ''}
|
|
1142
|
+
@input=${(event) => this.#writeSlot(slot.name, event.target.value)}
|
|
1143
|
+
/>
|
|
1144
|
+
</td>
|
|
1145
|
+
</tr>`;
|
|
1146
|
+
}));
|
|
1147
|
+
}
|
|
1148
|
+
#cssTable(facts) {
|
|
1149
|
+
const styles = this.#state.value.styles ?? {};
|
|
1150
|
+
return this.#group('css-properties', "CSS custom properties: values set on the preview element's style.", ['CSS custom properties', 'Syntax', 'Default', 'Description', 'Control / current value'], facts.cssProperties.map((entry) => {
|
|
1151
|
+
const value = styles[entry.name];
|
|
1152
|
+
return html `<tr data-css=${entry.name}>
|
|
1153
|
+
${named(entry.name, undefined)}
|
|
1154
|
+
<td data-label="Syntax">${entry.type ?? 'Unknown'}</td>
|
|
1155
|
+
<td data-label="Default">${entry.default ?? 'Unknown'}</td>
|
|
1156
|
+
<td data-label="Description" colspan="2">
|
|
1157
|
+
${entry.description ? inline(entry.description) : html `<span class="wb-not-documented">Not documented</span>`}
|
|
1158
|
+
</td>
|
|
1159
|
+
<td data-label="Control">
|
|
1160
|
+
${isCustomPropertyName(entry.name)
|
|
1161
|
+
? html `<div class="wb-cell-controls">
|
|
1162
|
+
<input
|
|
1163
|
+
type="text"
|
|
1164
|
+
aria-label=${`${entry.name} value`}
|
|
1165
|
+
placeholder="unset"
|
|
1166
|
+
.value=${value ?? ''}
|
|
1167
|
+
@input=${(event) => {
|
|
1168
|
+
const text = event.target.value;
|
|
1169
|
+
this.#writeStyle(entry.name, text === '' ? null : text);
|
|
1170
|
+
}}
|
|
1171
|
+
/>
|
|
1172
|
+
<button
|
|
1173
|
+
type="button"
|
|
1174
|
+
?disabled=${value === undefined}
|
|
1175
|
+
@click=${() => this.#writeStyle(entry.name, null)}
|
|
1176
|
+
>
|
|
1177
|
+
Unset
|
|
1178
|
+
</button>
|
|
1179
|
+
</div>`
|
|
1180
|
+
: html `<span class="wb-muted">Not editable: not a custom property name.</span>`}
|
|
1181
|
+
</td>
|
|
1182
|
+
</tr>`;
|
|
1183
|
+
}));
|
|
1184
|
+
}
|
|
1185
|
+
/** The published examples as cards (Figma 20:2266), each with a live specimen when the host gives one. */
|
|
1186
|
+
#examples(page) {
|
|
1187
|
+
const preview = this.examplePreview;
|
|
1188
|
+
const tools = this.layout === 'workbench';
|
|
1189
|
+
const shown = presets(page).filter((e) => !e.incorrect && (tools || !e.requiresFixture));
|
|
1190
|
+
// Presets lead; the reference layout shows four and keeps the rest one action away.
|
|
1191
|
+
const ordered = [...shown.filter((e) => e.preset), ...shown.filter((e) => !e.preset)];
|
|
1192
|
+
const all = tools || this.#allExamples.value;
|
|
1193
|
+
return html `<p class="wb-lede">Compare the published examples without leaving the page.</p>
|
|
1194
|
+
<ul class="wb-boxes wb-examples" role="list">
|
|
1195
|
+
${(all ? ordered : ordered.slice(0, 4)).map((entry) => {
|
|
1196
|
+
const node = preview?.(page, entry.id);
|
|
1197
|
+
return html `<li class="wb-box" data-example=${entry.id}>
|
|
1198
|
+
${node ? html `<div class="wb-specimen">${node}</div>` : nothing}
|
|
1199
|
+
<div class="wb-box-body">
|
|
1200
|
+
<h3>${entry.label}</h3>
|
|
1201
|
+
${entry.guidance ? html `<p>${inline(entry.guidance)}</p>` : nothing}
|
|
1202
|
+
${entry.requiresFixture
|
|
1203
|
+
? html `<p class="wb-muted">
|
|
1204
|
+
Requires fixture: rendered by its example module only.
|
|
1205
|
+
</p>`
|
|
1206
|
+
: !tools
|
|
1207
|
+
? nothing
|
|
1208
|
+
: entry.state
|
|
1209
|
+
? html `<button type="button" @click=${() => this.#applyPreset(entry)}>
|
|
1210
|
+
Show in preview
|
|
1211
|
+
</button>`
|
|
1212
|
+
: html `<p class="wb-muted">
|
|
1213
|
+
Values not published (the example module was not loaded).
|
|
1214
|
+
</p>`}
|
|
1215
|
+
</div>
|
|
1216
|
+
</li>`;
|
|
1217
|
+
})}
|
|
1218
|
+
</ul>
|
|
1219
|
+
${!tools && ordered.length > 4
|
|
1220
|
+
? html `<button
|
|
1221
|
+
type="button"
|
|
1222
|
+
class="wb-more"
|
|
1223
|
+
aria-expanded=${all ? 'true' : 'false'}
|
|
1224
|
+
@click=${() => (this.#allExamples.value = !all)}
|
|
1225
|
+
>
|
|
1226
|
+
${all ? 'Show fewer examples' : `Show all ${ordered.length} examples`}
|
|
1227
|
+
</button>`
|
|
1228
|
+
: nothing}`;
|
|
1229
|
+
}
|
|
1230
|
+
#tokens(page, at) {
|
|
1231
|
+
const mappings = Array.isArray(page.enrichment?.['styleMappings'])
|
|
1232
|
+
? page.enrichment['styleMappings'].filter(isRecord)
|
|
1233
|
+
: [];
|
|
1234
|
+
const reads = Array.isArray(page.enrichment?.['tokenReads'])
|
|
1235
|
+
? page.enrichment['tokenReads'].filter(isRecord)
|
|
1236
|
+
: [];
|
|
1237
|
+
const resolved = new Map((page.tokens ?? []).map((t) => [t.name, t]));
|
|
1238
|
+
const theme = this.index?.libraries.length ? 'the generated baseline theme' : 'the baseline';
|
|
1239
|
+
return html `<h3>From property to token</h3>
|
|
1240
|
+
${at('tokens/style-mappings', () => html `<div class="wb-table-wrap">
|
|
1241
|
+
<table class="wb-stack" data-table="style-mappings">
|
|
1242
|
+
<caption>
|
|
1243
|
+
Declared style mappings extracted from the component's stylesheets.
|
|
1244
|
+
</caption>
|
|
1245
|
+
<thead>
|
|
1246
|
+
<tr>
|
|
1247
|
+
<th scope="col">When</th>
|
|
1248
|
+
<th scope="col">Part</th>
|
|
1249
|
+
<th scope="col">CSS property</th>
|
|
1250
|
+
<th scope="col">Token</th>
|
|
1251
|
+
</tr>
|
|
1252
|
+
</thead>
|
|
1253
|
+
<tbody>
|
|
1254
|
+
${mappings.map((m) => html `<tr>
|
|
1255
|
+
<td data-label="When"><code>${String(m['when'] ?? 'default')}</code></td>
|
|
1256
|
+
<td data-label="Part">
|
|
1257
|
+
${m['part'] ? html `<code>${String(m['part'])}</code>` : 'host'}
|
|
1258
|
+
</td>
|
|
1259
|
+
<td data-label="CSS property">
|
|
1260
|
+
<code>${String(m['cssProperty'] ?? '')}</code>
|
|
1261
|
+
</td>
|
|
1262
|
+
<td data-label="Token"><code>${String(m['token'] ?? '')}</code></td>
|
|
1263
|
+
</tr>`)}
|
|
1264
|
+
</tbody>
|
|
1265
|
+
</table>
|
|
1266
|
+
</div>`)}
|
|
1267
|
+
<h3>Every token read</h3>
|
|
1268
|
+
${at('tokens/token-reads', () => html `<div class="wb-table-wrap">
|
|
1269
|
+
<table class="wb-stack" data-table="token-reads">
|
|
1270
|
+
<caption>
|
|
1271
|
+
${resolved.size} tokens read · values for ${theme} (not a live preview reading)
|
|
1272
|
+
</caption>
|
|
1273
|
+
<thead>
|
|
1274
|
+
<tr>
|
|
1275
|
+
<th scope="col">Token</th>
|
|
1276
|
+
<th scope="col">Reference</th>
|
|
1277
|
+
<th scope="col">Read through</th>
|
|
1278
|
+
<th scope="col">Value</th>
|
|
1279
|
+
</tr>
|
|
1280
|
+
</thead>
|
|
1281
|
+
<tbody>
|
|
1282
|
+
${[...new Map(reads.map((r) => [String(r['token']), r])).values()].map((read) => {
|
|
1283
|
+
const name = String(read['token']);
|
|
1284
|
+
const token = resolved.get(name);
|
|
1285
|
+
const definition = token &&
|
|
1286
|
+
typeof token.definition === 'string' &&
|
|
1287
|
+
token.definition.startsWith('{')
|
|
1288
|
+
? token.definition
|
|
1289
|
+
: undefined;
|
|
1290
|
+
return html `<tr>
|
|
1291
|
+
<td data-label="Token">
|
|
1292
|
+
<code>${name}</code>
|
|
1293
|
+
${token?.cssVariable ? html `<small><code>${token.cssVariable}</code></small>` : nothing}
|
|
1294
|
+
</td>
|
|
1295
|
+
<td data-label="Reference">${String(read['kind'] ?? 'declared')}</td>
|
|
1296
|
+
<td data-label="Read through">${String(read['readThrough'] ?? 'Unknown')}</td>
|
|
1297
|
+
<td data-label="Value">
|
|
1298
|
+
${token && !('missing' in token) && token.value !== undefined
|
|
1299
|
+
? html `<code>${String(token.value)}</code
|
|
1300
|
+
>${definition ? html ` <small><code>${definition}</code></small>` : nothing}`
|
|
1301
|
+
: 'Unknown'}
|
|
1302
|
+
</td>
|
|
1303
|
+
</tr>`;
|
|
1304
|
+
})}
|
|
1305
|
+
</tbody>
|
|
1306
|
+
</table>
|
|
1307
|
+
</div>`)}`;
|
|
1308
|
+
}
|
|
1309
|
+
/** Accessibility as two cards (Figma 20:2292): behavior, then keyboard and guidance. */
|
|
1310
|
+
#behavior(page, at) {
|
|
1311
|
+
const card = (title, key) => {
|
|
1312
|
+
const items = authoredValues(page.enrichment?.[key]);
|
|
1313
|
+
return this.layout === 'reference' && !items.length
|
|
1314
|
+
? nothing
|
|
1315
|
+
: html `<div class="wb-box">
|
|
1316
|
+
<h3>${title}</h3>
|
|
1317
|
+
${at(`behavior-and-accessibility/${key}`, () => bullets(items))}
|
|
1318
|
+
</div>`;
|
|
1319
|
+
};
|
|
1320
|
+
return html `<div class="wb-boxes">
|
|
1321
|
+
${card('Behavior', 'a11y')} ${card('Keyboard and guidance', 'keyboard')}
|
|
1322
|
+
</div>`;
|
|
1323
|
+
}
|
|
1324
|
+
/** Where the element lives and how to register it (Figma 232:9845). */
|
|
1325
|
+
#source(page, at) {
|
|
1326
|
+
const definition = isRecord(page.enrichment?.['definition'])
|
|
1327
|
+
? page.enrichment['definition']
|
|
1328
|
+
: undefined;
|
|
1329
|
+
const ref = isRecord(definition?.['ref']) ? definition['ref'] : undefined;
|
|
1330
|
+
const field = (label, ...values) => html `<div class="wb-fact">
|
|
1331
|
+
<span class="wb-fact-label">${label}</span>
|
|
1332
|
+
${values.map((value) => html `<code
|
|
1333
|
+
>${String(value)
|
|
1334
|
+
.split('/')
|
|
1335
|
+
.map((part, i) => (i ? html `/<wbr />${part}` : part))}</code
|
|
1336
|
+
>`)}
|
|
1337
|
+
</div>`;
|
|
1338
|
+
return html `<p class="wb-lede">
|
|
1339
|
+
Where the element is published, so it is easy to locate, import, and verify.
|
|
1340
|
+
</p>
|
|
1341
|
+
${at('source/module', () => html `<div class="wb-facts">
|
|
1342
|
+
${field('Package', page.package)} ${field('Module', page.module)}
|
|
1343
|
+
${field('Class', page.declaration['name'] ?? 'Unknown')}
|
|
1344
|
+
${ref ? field('Definition', ref['name'], ref['module']) : nothing}
|
|
1345
|
+
</div>`)}
|
|
1346
|
+
${at('source/register', () => html `<div class="wb-fact wb-register">
|
|
1347
|
+
<span class="wb-fact-label">Register</span>
|
|
1348
|
+
<code>import '${String(registrationOf(page))}';</code>
|
|
1349
|
+
</div>`)}`;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
//# sourceMappingURL=component-detail.js.map
|