@nysds/playground 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +25 -0
- package/README.md +364 -0
- package/bin/cli.mjs +223 -0
- package/bin/cli.test.mjs +36 -0
- package/decks/customizing-components.json +97 -0
- package/dist/assets/index-LO1tomgR.css +6 -0
- package/dist/assets/index-bBtGCGL_.js +5190 -0
- package/dist/assets/internal/typescript.js +193739 -0
- package/dist/assets/nys-icon.library-Bi_7DKlD-YSs5zqZy-DXISxj9N.js +609 -0
- package/dist/assets/nys-icon.library-CwuPZJAc-TryaOS7Z.js +600 -0
- package/dist/assets/playground-typescript-worker-BcTrPYfY.js +87 -0
- package/dist/assets/playground-typescript-worker.js +87 -0
- package/dist/favicon.svg +10 -0
- package/dist/index.html +769 -0
- package/dist/nysds-logo.svg +21 -0
- package/dist/nysds-symbol.svg +7 -0
- package/index.html +768 -0
- package/package.json +59 -0
- package/presets/00-welcome.json +8 -0
- package/presets/01-button.json +7 -0
- package/presets/02-alert.json +7 -0
- package/presets/03-badge-and-avatar.json +7 -0
- package/presets/04-text-input.json +7 -0
- package/presets/05-select-radio-checkbox.json +7 -0
- package/presets/06-form-validation.json +7 -0
- package/presets/07-card.json +7 -0
- package/presets/08-accordion.json +7 -0
- package/presets/09-tabs.json +7 -0
- package/presets/10-modal.json +7 -0
- package/presets/11-stepper.json +7 -0
- package/presets/12-table-and-pagination.json +7 -0
- package/presets/13-tooltip-and-dropdown.json +7 -0
- package/presets/14-navigation.json +7 -0
- package/presets/15-page-structure.json +7 -0
- package/presets/16-themes.json +7 -0
- package/presets/17-utility-classes.json +7 -0
- package/presets/README.md +118 -0
- package/public/favicon.svg +10 -0
- package/public/nysds-logo.svg +21 -0
- package/public/nysds-symbol.svg +7 -0
- package/src/app.css +1087 -0
- package/src/debounce.ts +86 -0
- package/src/deck-model.ts +299 -0
- package/src/deck-store.ts +144 -0
- package/src/decks.test.ts +288 -0
- package/src/editor-panes.ts +190 -0
- package/src/editors.ts +92 -0
- package/src/home.ts +225 -0
- package/src/icon-names.ts +117 -0
- package/src/icons.test.ts +58 -0
- package/src/keys.ts +162 -0
- package/src/main.ts +1456 -0
- package/src/playground.config.ts +74 -0
- package/src/playground.ts +261 -0
- package/src/present.ts +398 -0
- package/src/preset-schema.ts +228 -0
- package/src/route.test.ts +56 -0
- package/src/routing.ts +60 -0
- package/src/settings.ts +211 -0
- package/src/starters.ts +86 -0
- package/src/state.test.ts +544 -0
- package/src/state.ts +237 -0
- package/src/theme.ts +82 -0
- package/src/version-catalog.ts +42 -0
- package/src/versions.ts +88 -0
- package/src/wrapper.ts +84 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +62 -0
package/src/present.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation mode.
|
|
3
|
+
*
|
|
4
|
+
* With `?present=1` the toolbar is hidden and the preview fills most of the
|
|
5
|
+
* viewport, but the editors stay visible and editable so the presenter can
|
|
6
|
+
* change a slide live. A caption bar at the bottom names the slide and carries
|
|
7
|
+
* the controls.
|
|
8
|
+
*/
|
|
9
|
+
import type {Slide, StoredDeck} from './deck-model';
|
|
10
|
+
import {slideIndex} from './deck-model';
|
|
11
|
+
import type {EditorPanes} from './editor-panes';
|
|
12
|
+
import type {PaneId} from './editors';
|
|
13
|
+
import {PANE_IDS} from './editors';
|
|
14
|
+
import type {PresentationAction} from './keys';
|
|
15
|
+
import {hintText, isTypingContext, routeKey} from './keys';
|
|
16
|
+
import {applyEditorTheme, initialTheme, otherTheme, themeUrl, writeTheme} from './theme';
|
|
17
|
+
import {STORAGE_KEYS, readKey, writeKey} from './settings';
|
|
18
|
+
import {presentUrl} from './state';
|
|
19
|
+
|
|
20
|
+
/** What presentation mode needs from the application shell. */
|
|
21
|
+
export interface PresentationTarget {
|
|
22
|
+
/** Records whether the playground is presenting. */
|
|
23
|
+
setPresent(present: boolean): void;
|
|
24
|
+
/** The deck being presented. */
|
|
25
|
+
getDeck(): StoredDeck;
|
|
26
|
+
/** The id of the slide on screen, or `null` for a shared code link. */
|
|
27
|
+
getActivePresetId(): string | null;
|
|
28
|
+
/** Loads a slide, restoring any edits made to it earlier in the session. */
|
|
29
|
+
loadPreset(preset: Slide): void;
|
|
30
|
+
/** Discards this session's edits to the current slide. */
|
|
31
|
+
resetSlide(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How long the keyboard hint stays on screen, in milliseconds. */
|
|
35
|
+
const HINT_DURATION_MS = 6000;
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
/** Drives the slideshow. */
|
|
40
|
+
export class Presentation {
|
|
41
|
+
private readonly target: PresentationTarget;
|
|
42
|
+
private readonly app: HTMLElement;
|
|
43
|
+
private readonly caption: HTMLElement;
|
|
44
|
+
private readonly group: HTMLElement;
|
|
45
|
+
private readonly title: HTMLElement;
|
|
46
|
+
private readonly description: HTMLElement;
|
|
47
|
+
private readonly hint: HTMLElement;
|
|
48
|
+
private readonly notes: HTMLElement;
|
|
49
|
+
private readonly notesBody: HTMLElement;
|
|
50
|
+
private readonly notesButton: HTMLElement;
|
|
51
|
+
private readonly panes: EditorPanes;
|
|
52
|
+
private collapsed = false;
|
|
53
|
+
private notesOpen = false;
|
|
54
|
+
private presenting = false;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
target: PresentationTarget,
|
|
58
|
+
panes: EditorPanes,
|
|
59
|
+
root: Document | HTMLElement = document,
|
|
60
|
+
) {
|
|
61
|
+
this.target = target;
|
|
62
|
+
this.panes = panes;
|
|
63
|
+
this.app = required(root, '#app');
|
|
64
|
+
this.caption = required(root, '#caption');
|
|
65
|
+
this.group = required(root, '#caption-group');
|
|
66
|
+
this.title = required(root, '#caption-title');
|
|
67
|
+
this.description = required(root, '#caption-description');
|
|
68
|
+
this.hint = required(root, '#present-hint');
|
|
69
|
+
this.notes = required(root, '#notes');
|
|
70
|
+
this.notesBody = required(root, '#notes-body');
|
|
71
|
+
this.notesButton = required(root, '#notes-button');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Binds the slide bar, then enters presentation mode when asked. */
|
|
75
|
+
start(present: boolean): void {
|
|
76
|
+
this.collapsed = readCollapsed();
|
|
77
|
+
this.applyCollapsed();
|
|
78
|
+
this.hidePreviewToolbar();
|
|
79
|
+
bindClick('#prev-button', () => this.step(-1));
|
|
80
|
+
bindClick('#next-button', () => this.step(1));
|
|
81
|
+
bindClick('#reset-slide-button', () => {
|
|
82
|
+
this.target.resetSlide();
|
|
83
|
+
this.refresh();
|
|
84
|
+
this.takeFocus();
|
|
85
|
+
});
|
|
86
|
+
bindClick('#notes-button', () => this.toggleNotes());
|
|
87
|
+
bindClick('#notes-close', () => this.closeNotes());
|
|
88
|
+
window.addEventListener('keydown', (event) => this.onKeyDown(event), true);
|
|
89
|
+
this.refresh();
|
|
90
|
+
if (present) {
|
|
91
|
+
this.setPresenting(true);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Enters presentation mode.
|
|
97
|
+
*
|
|
98
|
+
* The page is not reloaded, so the click that asked for it still counts as
|
|
99
|
+
* the user gesture that fullscreen requires.
|
|
100
|
+
*/
|
|
101
|
+
enter(): void {
|
|
102
|
+
void document.documentElement.requestFullscreen?.().catch(() => undefined);
|
|
103
|
+
this.setPresenting(true);
|
|
104
|
+
this.takeFocus();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Leaves presentation mode, keeping the deck and slide in the URL. */
|
|
108
|
+
exit(): void {
|
|
109
|
+
if (document.fullscreenElement) {
|
|
110
|
+
void document.exitFullscreen?.().catch(() => undefined);
|
|
111
|
+
}
|
|
112
|
+
this.setPresenting(false);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether the playground is presenting. */
|
|
116
|
+
get isPresenting(): boolean {
|
|
117
|
+
return this.presenting;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private setPresenting(presenting: boolean): void {
|
|
121
|
+
this.presenting = presenting;
|
|
122
|
+
this.app.classList.toggle('app--present', presenting);
|
|
123
|
+
this.hint.hidden = true;
|
|
124
|
+
this.hint.classList.remove('present-hint--fading');
|
|
125
|
+
if (presenting) {
|
|
126
|
+
this.showHint();
|
|
127
|
+
} else {
|
|
128
|
+
this.closeNotes();
|
|
129
|
+
}
|
|
130
|
+
this.target.setPresent(presenting);
|
|
131
|
+
window.history.replaceState(null, '', presentUrl(presenting));
|
|
132
|
+
this.refresh();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Redraws the caption and the notes from whatever is currently loaded. */
|
|
136
|
+
refresh(): void {
|
|
137
|
+
const deck = this.target.getDeck();
|
|
138
|
+
const id = this.target.getActivePresetId();
|
|
139
|
+
const index = slideIndex(deck, id);
|
|
140
|
+
const preset = index >= 0 ? deck.slides[index] : undefined;
|
|
141
|
+
this.group.textContent = preset?.group ?? '';
|
|
142
|
+
this.title.textContent = preset?.title ?? 'Custom code';
|
|
143
|
+
this.description.textContent = preset?.description ?? '';
|
|
144
|
+
const counter =
|
|
145
|
+
deck.slides.length === 0
|
|
146
|
+
? ''
|
|
147
|
+
: `${index >= 0 ? index + 1 : '—'} / ${deck.slides.length}`;
|
|
148
|
+
// One counter sits with the icons and another rides with the title on
|
|
149
|
+
// narrow screens; only one of them is ever visible.
|
|
150
|
+
for (const element of document.querySelectorAll<HTMLElement>('[data-count]')) {
|
|
151
|
+
element.textContent = counter;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const notes = preset?.notes ?? '';
|
|
155
|
+
this.notesBody.textContent = notes;
|
|
156
|
+
this.notesButton.hidden = notes === '';
|
|
157
|
+
// The tooltip has to go with its trigger, or it names a button nobody can
|
|
158
|
+
// see.
|
|
159
|
+
const notesTooltip = document.querySelector<HTMLElement>('nys-tooltip[for="notes-button"]');
|
|
160
|
+
if (notesTooltip) {
|
|
161
|
+
notesTooltip.hidden = notes === '';
|
|
162
|
+
}
|
|
163
|
+
if (!notes) {
|
|
164
|
+
this.closeNotes();
|
|
165
|
+
}
|
|
166
|
+
this.hint.textContent = hintText(notes !== '', this.panes.layout === 'columns');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Moves to the slide at `index`, clamped to the deck. */
|
|
170
|
+
goTo(index: number): void {
|
|
171
|
+
const deck = this.target.getDeck();
|
|
172
|
+
if (deck.slides.length === 0) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const clamped = Math.min(Math.max(index, 0), deck.slides.length - 1);
|
|
176
|
+
const preset = deck.slides[clamped];
|
|
177
|
+
if (preset) {
|
|
178
|
+
this.closeNotes();
|
|
179
|
+
this.target.loadPreset(preset);
|
|
180
|
+
this.refresh();
|
|
181
|
+
this.takeFocus();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private step(delta: number): void {
|
|
186
|
+
const deck = this.target.getDeck();
|
|
187
|
+
const current = slideIndex(deck, this.target.getActivePresetId());
|
|
188
|
+
// From a shared code link, stepping forward starts the deck and stepping
|
|
189
|
+
// back lands on the last slide.
|
|
190
|
+
const next = current === -1 ? (delta > 0 ? 0 : deck.slides.length - 1) : current + delta;
|
|
191
|
+
this.goTo(next);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private onKeyDown(event: KeyboardEvent): void {
|
|
195
|
+
// A dialog owns the keyboard while it is open, including its own Escape.
|
|
196
|
+
// Read the property rather than the attribute: the attribute only lands on
|
|
197
|
+
// the component's next render, which is a frame too late.
|
|
198
|
+
if (anyModalOpen()) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// While editing, these keys belong to the slide bar. Alt plus an arrow
|
|
202
|
+
// still steps from anywhere, as it does while presenting.
|
|
203
|
+
if (!this.presenting && !event.altKey && !this.caption.contains(event.target as Node)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const typing = isTypingContext(tagNamesFor(event));
|
|
207
|
+
const action = routeKey(event, typing, this.panes.layout === 'columns');
|
|
208
|
+
if (action === null) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// Escape closes the notes before it leaves presentation mode.
|
|
212
|
+
if (action === 'exit' && this.notesOpen) {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
this.closeNotes();
|
|
215
|
+
this.takeFocus();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
this.run(action);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private run(action: PresentationAction): void {
|
|
223
|
+
switch (action) {
|
|
224
|
+
case 'next':
|
|
225
|
+
this.step(1);
|
|
226
|
+
break;
|
|
227
|
+
case 'prev':
|
|
228
|
+
this.step(-1);
|
|
229
|
+
break;
|
|
230
|
+
case 'first':
|
|
231
|
+
this.goTo(0);
|
|
232
|
+
break;
|
|
233
|
+
case 'last':
|
|
234
|
+
this.goTo(this.target.getDeck().slides.length - 1);
|
|
235
|
+
break;
|
|
236
|
+
case 'toggle-code':
|
|
237
|
+
this.toggleCode();
|
|
238
|
+
break;
|
|
239
|
+
case 'toggle-notes':
|
|
240
|
+
this.toggleNotes();
|
|
241
|
+
break;
|
|
242
|
+
case 'toggle-layout':
|
|
243
|
+
this.panes.toggleLayout();
|
|
244
|
+
this.refresh();
|
|
245
|
+
this.takeFocus();
|
|
246
|
+
break;
|
|
247
|
+
case 'toggle-theme':
|
|
248
|
+
this.toggleTheme();
|
|
249
|
+
break;
|
|
250
|
+
case 'exit':
|
|
251
|
+
this.exit();
|
|
252
|
+
break;
|
|
253
|
+
default: {
|
|
254
|
+
const pane = paneFor(action);
|
|
255
|
+
if (pane) {
|
|
256
|
+
this.panes.togglePane(pane);
|
|
257
|
+
this.takeFocus();
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Switches the editors between light and dark and remembers the choice. */
|
|
265
|
+
private toggleTheme(): void {
|
|
266
|
+
const next = otherTheme(initialTheme());
|
|
267
|
+
applyEditorTheme(next);
|
|
268
|
+
writeTheme(next);
|
|
269
|
+
window.history.replaceState(null, '', themeUrl(next, window.location.href));
|
|
270
|
+
this.takeFocus();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private toggleCode(): void {
|
|
274
|
+
this.collapsed = !this.collapsed;
|
|
275
|
+
this.applyCollapsed();
|
|
276
|
+
writeCollapsed(this.collapsed);
|
|
277
|
+
this.takeFocus();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private applyCollapsed(): void {
|
|
281
|
+
this.app.classList.toggle('app--code-collapsed', this.collapsed);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private toggleNotes(): void {
|
|
285
|
+
if (this.notesOpen) {
|
|
286
|
+
this.closeNotes();
|
|
287
|
+
this.takeFocus();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!this.notesBody.textContent) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.notesOpen = true;
|
|
294
|
+
this.notes.hidden = false;
|
|
295
|
+
this.notes.focus?.();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private closeNotes(): void {
|
|
299
|
+
this.notesOpen = false;
|
|
300
|
+
this.notes.hidden = true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Moves focus to the caption bar.
|
|
305
|
+
*
|
|
306
|
+
* The preview is a cross-origin iframe. Once it holds focus, key events go
|
|
307
|
+
* to the sandbox instead of this page, so the deck stops responding to the
|
|
308
|
+
* arrow keys. Reclaiming focus after every step keeps them working. After an
|
|
309
|
+
* edit, focus stays in the editor, which is what the presenter wants.
|
|
310
|
+
*/
|
|
311
|
+
private takeFocus(): void {
|
|
312
|
+
this.caption.focus({preventScroll: true});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Hides the preview's own header bar so the slide fills the screen.
|
|
317
|
+
*
|
|
318
|
+
* `playground-preview` exposes no CSS part for the bar, so the rule goes
|
|
319
|
+
* into its shadow root. Setting `--playground-bar-height` alone leaves the
|
|
320
|
+
* label and the reload button visible.
|
|
321
|
+
*/
|
|
322
|
+
private hidePreviewToolbar(): void {
|
|
323
|
+
const root = document.querySelector('#preview')?.shadowRoot;
|
|
324
|
+
if (!root) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const sheet = new CSSStyleSheet();
|
|
329
|
+
sheet.replaceSync('#toolbar { display: none; }');
|
|
330
|
+
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
|
|
331
|
+
} catch {
|
|
332
|
+
// Constructable stylesheets are optional. The bar is only cosmetic.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private showHint(): void {
|
|
337
|
+
this.hint.hidden = false;
|
|
338
|
+
window.setTimeout(() => {
|
|
339
|
+
this.hint.classList.add('present-hint--fading');
|
|
340
|
+
window.setTimeout(() => {
|
|
341
|
+
this.hint.hidden = true;
|
|
342
|
+
}, 700);
|
|
343
|
+
}, HINT_DURATION_MS);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Returns the column a `toggle-pane:` action names, or `undefined`. */
|
|
348
|
+
function paneFor(action: PresentationAction): PaneId | undefined {
|
|
349
|
+
const prefix = 'toggle-pane:';
|
|
350
|
+
if (typeof action !== 'string' || !action.startsWith(prefix)) {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
const pane = action.slice(prefix.length);
|
|
354
|
+
return PANE_IDS.find((candidate) => candidate === pane);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Returns the lowercase tag names on a key event's composed path. */
|
|
358
|
+
function tagNamesFor(event: KeyboardEvent): string[] {
|
|
359
|
+
const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
|
|
360
|
+
const names: string[] = [];
|
|
361
|
+
for (const node of path) {
|
|
362
|
+
const tag = (node as Element).tagName;
|
|
363
|
+
if (typeof tag === 'string') {
|
|
364
|
+
names.push(tag.toLowerCase());
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (names.length === 0 && event.target instanceof Element) {
|
|
368
|
+
names.push(event.target.tagName.toLowerCase());
|
|
369
|
+
}
|
|
370
|
+
return names;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function readCollapsed(): boolean {
|
|
374
|
+
return readKey(STORAGE_KEYS.codeCollapsed) === '1';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function writeCollapsed(collapsed: boolean): void {
|
|
378
|
+
writeKey(STORAGE_KEYS.codeCollapsed, collapsed ? '1' : '0');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Reports whether any design system dialog is on screen. */
|
|
382
|
+
function anyModalOpen(): boolean {
|
|
383
|
+
return [...document.querySelectorAll('nys-modal')].some(
|
|
384
|
+
(modal) => (modal as HTMLElement & {open?: boolean}).open === true,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function bindClick(selector: string, handler: () => void): void {
|
|
389
|
+
document.querySelector(selector)?.addEventListener('nys-click', handler);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function required(root: Document | HTMLElement, selector: string): HTMLElement {
|
|
393
|
+
const element = root.querySelector<HTMLElement>(selector);
|
|
394
|
+
if (!element) {
|
|
395
|
+
throw new Error(`The page is missing the element "${selector}".`);
|
|
396
|
+
}
|
|
397
|
+
return element;
|
|
398
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types and validation for preset and deck JSON.
|
|
3
|
+
*
|
|
4
|
+
* This module imports nothing, so `src/state.test.ts` can load it directly
|
|
5
|
+
* under `node --test`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One of the three editor columns. */
|
|
9
|
+
export type PaneId = 'html' | 'css' | 'js';
|
|
10
|
+
|
|
11
|
+
/** The editor columns, in the order they appear. */
|
|
12
|
+
export const PANE_IDS: readonly PaneId[] = ['html', 'css', 'js'];
|
|
13
|
+
|
|
14
|
+
/** One playground example. */
|
|
15
|
+
export interface Preset {
|
|
16
|
+
/** The id used in `#preset=`. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** The short name shown in the menu, the caption, and the page title. */
|
|
19
|
+
title: string;
|
|
20
|
+
/** One sentence shown in the presentation caption. */
|
|
21
|
+
description: string;
|
|
22
|
+
/** An optional section label, such as `1. Lock it down`. */
|
|
23
|
+
group: string;
|
|
24
|
+
/** Presenter notes. Never rendered in the preview. */
|
|
25
|
+
notes: string;
|
|
26
|
+
/** The HTML the example loads into the HTML tab. */
|
|
27
|
+
html: string;
|
|
28
|
+
/** The CSS the example loads into the CSS tab. */
|
|
29
|
+
css: string;
|
|
30
|
+
/** The JavaScript the example loads into the JS tab. */
|
|
31
|
+
js: string;
|
|
32
|
+
/** The design system version to switch to, or `latest`. */
|
|
33
|
+
version: string;
|
|
34
|
+
/**
|
|
35
|
+
* Which editor columns to expand when this example loads, as any of
|
|
36
|
+
* `"html"`, `"css"`, and `"js"`.
|
|
37
|
+
*
|
|
38
|
+
* It applies only to the side-by-side layout, and every column not listed is
|
|
39
|
+
* collapsed. An empty array collapses all three. `null` means the file left
|
|
40
|
+
* the field out, which leaves the current columns as they are.
|
|
41
|
+
*/
|
|
42
|
+
editors: PaneId[] | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An ordered set of examples that presentation mode steps through. */
|
|
46
|
+
export interface Deck {
|
|
47
|
+
/** The id used in `?deck=`. Derived from the filename. */
|
|
48
|
+
id: string;
|
|
49
|
+
/** The name shown in the deck menu. */
|
|
50
|
+
title: string;
|
|
51
|
+
/** One sentence describing the deck. */
|
|
52
|
+
description: string;
|
|
53
|
+
/** CSS injected into every slide's hidden head. */
|
|
54
|
+
baseCss: string;
|
|
55
|
+
/** The slides, in order. */
|
|
56
|
+
presets: Preset[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Receives a validation message. Defaults to `console.error`. */
|
|
60
|
+
export type Report = (message: string) => void;
|
|
61
|
+
|
|
62
|
+
const defaultReport: Report = (message) => console.error(message);
|
|
63
|
+
|
|
64
|
+
const defaultWarn: Report = (message) => console.warn(message);
|
|
65
|
+
|
|
66
|
+
/** Turns `../presets/01-button.json` into `button`. */
|
|
67
|
+
export function idFromPresetPath(path: string): string {
|
|
68
|
+
const filename = path.split('/').pop() ?? path;
|
|
69
|
+
return filename.replace(/\.json$/i, '').replace(/^\d+[-_]/, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Turns `../decks/styling-levels.json` into `styling-levels`. */
|
|
73
|
+
export function idFromDeckPath(path: string): string {
|
|
74
|
+
const filename = path.split('/').pop() ?? path;
|
|
75
|
+
return filename.replace(/\.json$/i, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Reads a string field, reporting anything that is present but not a string. */
|
|
79
|
+
function readString(
|
|
80
|
+
record: Record<string, unknown>,
|
|
81
|
+
key: string,
|
|
82
|
+
path: string,
|
|
83
|
+
fallback: string,
|
|
84
|
+
report: Report,
|
|
85
|
+
): string {
|
|
86
|
+
const value = record[key];
|
|
87
|
+
if (typeof value === 'string') {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
if (value !== undefined) {
|
|
91
|
+
report(`Preset "${path}" has a "${key}" field of type ${typeof value}. Expected a string.`);
|
|
92
|
+
}
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reads an optional `editors` array.
|
|
98
|
+
*
|
|
99
|
+
* Returns `null` when the field is absent. Entries that are not pane ids are
|
|
100
|
+
* reported and dropped, so one typo does not cost the whole example.
|
|
101
|
+
*/
|
|
102
|
+
function readPanes(
|
|
103
|
+
record: Record<string, unknown>,
|
|
104
|
+
path: string,
|
|
105
|
+
report: Report,
|
|
106
|
+
): PaneId[] | null {
|
|
107
|
+
const value = record['editors'];
|
|
108
|
+
if (value === undefined) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(value)) {
|
|
112
|
+
report(
|
|
113
|
+
`Preset "${path}" has an "editors" field of type ${typeof value}. ` +
|
|
114
|
+
'Expected an array of "html", "css", or "js".',
|
|
115
|
+
);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const panes: PaneId[] = [];
|
|
119
|
+
for (const entry of value) {
|
|
120
|
+
if (typeof entry === 'string' && (PANE_IDS as readonly string[]).includes(entry)) {
|
|
121
|
+
if (!panes.includes(entry as PaneId)) {
|
|
122
|
+
panes.push(entry as PaneId);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
report(
|
|
126
|
+
`Preset "${path}" lists ${JSON.stringify(entry)} in "editors". ` +
|
|
127
|
+
'Expected "html", "css", or "js". Ignoring it.',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return panes;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
135
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Validates one preset.
|
|
140
|
+
*
|
|
141
|
+
* `fallbackId` is the id derived from the filename. Library presets always use
|
|
142
|
+
* it; deck slides must carry their own `id`.
|
|
143
|
+
*
|
|
144
|
+
* Returns `null` when the file cannot be used at all.
|
|
145
|
+
*/
|
|
146
|
+
export function parsePreset(
|
|
147
|
+
path: string,
|
|
148
|
+
raw: unknown,
|
|
149
|
+
fallbackId: string | null,
|
|
150
|
+
report: Report = defaultReport,
|
|
151
|
+
): Preset | null {
|
|
152
|
+
if (!isRecord(raw)) {
|
|
153
|
+
report(`Preset "${path}" is not a JSON object. Skipping it.`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const id = fallbackId ?? readString(raw, 'id', path, '', report);
|
|
157
|
+
if (!id) {
|
|
158
|
+
report(`Preset "${path}" is missing the required string field "id". Skipping it.`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (raw['title'] === undefined) {
|
|
162
|
+
report(`Preset "${path}" is missing the required string field "title".`);
|
|
163
|
+
}
|
|
164
|
+
if (raw['html'] === undefined) {
|
|
165
|
+
report(`Preset "${path}" is missing the required string field "html".`);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
title: readString(raw, 'title', path, id, report),
|
|
170
|
+
description: readString(raw, 'description', path, '', report),
|
|
171
|
+
group: readString(raw, 'group', path, '', report),
|
|
172
|
+
notes: readString(raw, 'notes', path, '', report),
|
|
173
|
+
html: readString(raw, 'html', path, '', report),
|
|
174
|
+
css: readString(raw, 'css', path, '', report),
|
|
175
|
+
js: readString(raw, 'js', path, '', report),
|
|
176
|
+
version: readString(raw, 'version', path, 'latest', report) || 'latest',
|
|
177
|
+
editors: readPanes(raw, path, report),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Validates one deck file. Returns `null` when the file is unusable.
|
|
183
|
+
*
|
|
184
|
+
* `report` receives errors; `warn` receives advisories, such as a deck that
|
|
185
|
+
* still carries a `boilerplate.head` key.
|
|
186
|
+
*/
|
|
187
|
+
export function parseDeck(
|
|
188
|
+
path: string,
|
|
189
|
+
raw: unknown,
|
|
190
|
+
report: Report = defaultReport,
|
|
191
|
+
warn: Report = defaultWarn,
|
|
192
|
+
): Deck | null {
|
|
193
|
+
if (!isRecord(raw)) {
|
|
194
|
+
report(`Deck "${path}" is not a JSON object. Skipping it.`);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const id = idFromDeckPath(path);
|
|
198
|
+
const rawPresets = raw['presets'];
|
|
199
|
+
if (!Array.isArray(rawPresets) || rawPresets.length === 0) {
|
|
200
|
+
report(`Deck "${path}" needs a non-empty "presets" array. Skipping it.`);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const boilerplate = isRecord(raw['boilerplate']) ? raw['boilerplate'] : {};
|
|
204
|
+
if (boilerplate['head'] !== undefined) {
|
|
205
|
+
warn(
|
|
206
|
+
`Deck "${path}" sets "boilerplate.head", which the playground ignores. ` +
|
|
207
|
+
'The playground already loads the design system, and the version menu controls it.',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const presets: Preset[] = [];
|
|
211
|
+
rawPresets.forEach((entry, index) => {
|
|
212
|
+
const preset = parsePreset(`${path}#presets[${index}]`, entry, null, report);
|
|
213
|
+
if (preset) {
|
|
214
|
+
presets.push(preset);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
if (presets.length === 0) {
|
|
218
|
+
report(`Deck "${path}" has no usable presets. Skipping it.`);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
id,
|
|
223
|
+
title: readString(raw, 'title', path, id, report),
|
|
224
|
+
description: readString(raw, 'description', path, '', report),
|
|
225
|
+
baseCss: typeof boilerplate['baseCss'] === 'string' ? boilerplate['baseCss'] : '',
|
|
226
|
+
presets,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Checks the view a URL asks for and when history moves need a reboot. */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
import {needsReboot, routeFor, sameRoute, slideIdFromHash} from './routing.ts';
|
|
6
|
+
|
|
7
|
+
test('a bare URL lands on the home page', () => {
|
|
8
|
+
assert.deepEqual(routeFor('', ''), {kind: 'home'});
|
|
9
|
+
assert.deepEqual(routeFor('?theme=dark', ''), {kind: 'home'});
|
|
10
|
+
assert.deepEqual(routeFor('?present=1', ''), {kind: 'home'});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('a deck id opens that deck', () => {
|
|
14
|
+
assert.deepEqual(routeFor('?deck=styling-levels', ''), {
|
|
15
|
+
kind: 'deck',
|
|
16
|
+
id: 'styling-levels',
|
|
17
|
+
});
|
|
18
|
+
assert.deepEqual(routeFor('?deck=a&present=1', '#preset=b'), {kind: 'deck', id: 'a'});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('a shared hash without a deck opens the scratch pad', () => {
|
|
22
|
+
assert.deepEqual(routeFor('', '#code=N4Igb'), {kind: 'scratch'});
|
|
23
|
+
assert.deepEqual(routeFor('', '#preset=button'), {kind: 'scratch'});
|
|
24
|
+
assert.deepEqual(routeFor('?theme=dark', '#code=x'), {kind: 'scratch'});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('moving between views needs a reboot', () => {
|
|
28
|
+
const home = routeFor('', '');
|
|
29
|
+
const scratch = routeFor('', '#code=x');
|
|
30
|
+
const deck = routeFor('?deck=a', '#preset=one');
|
|
31
|
+
const other = routeFor('?deck=b', '');
|
|
32
|
+
|
|
33
|
+
assert.equal(needsReboot(home, scratch), true);
|
|
34
|
+
assert.equal(needsReboot(scratch, home), true);
|
|
35
|
+
assert.equal(needsReboot(home, deck), true);
|
|
36
|
+
assert.equal(needsReboot(deck, other), true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('moving between slides of one deck does not', () => {
|
|
40
|
+
const first = routeFor('?deck=a', '#preset=one');
|
|
41
|
+
const second = routeFor('?deck=a&present=1', '#preset=two');
|
|
42
|
+
assert.equal(needsReboot(first, second), false);
|
|
43
|
+
assert.equal(sameRoute(first, second), true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the home page stays the home page whatever the settings say', () => {
|
|
47
|
+
assert.equal(needsReboot(routeFor('', ''), routeFor('?theme=dark&font=large', '')), false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('slideIdFromHash reads only a preset hash', () => {
|
|
51
|
+
assert.equal(slideIdFromHash('#preset=01-a'), '01-a');
|
|
52
|
+
assert.equal(slideIdFromHash('preset=01-a'), '01-a');
|
|
53
|
+
assert.equal(slideIdFromHash('#code=abc'), null);
|
|
54
|
+
assert.equal(slideIdFromHash(''), null);
|
|
55
|
+
assert.equal(slideIdFromHash('#preset='), null);
|
|
56
|
+
});
|