@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/home.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The home view: the list of decks in this browser and the ways to add one.
|
|
3
|
+
*/
|
|
4
|
+
import type {StoredDeck} from './deck-model';
|
|
5
|
+
import {relativeTime} from './deck-model';
|
|
6
|
+
|
|
7
|
+
/** Marks the one action whose glyph the design system does not ship. */
|
|
8
|
+
const PLAY_ICON = 'play';
|
|
9
|
+
|
|
10
|
+
/** Makes a deck id safe to put in an element id. */
|
|
11
|
+
function slug(value: string): string {
|
|
12
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What the home view asks the application to do. */
|
|
16
|
+
export interface HomeActions {
|
|
17
|
+
/** Opens a deck in the editor. */
|
|
18
|
+
open(id: string): void;
|
|
19
|
+
/** Opens a deck in presentation mode. */
|
|
20
|
+
present(id: string): void;
|
|
21
|
+
/** Copies a deck. */
|
|
22
|
+
duplicate(id: string): Promise<void>;
|
|
23
|
+
/** Downloads a deck as JSON. */
|
|
24
|
+
exportDeck(id: string): Promise<void>;
|
|
25
|
+
/** Asks to delete a deck, confirming first. */
|
|
26
|
+
remove(deck: StoredDeck): void;
|
|
27
|
+
/** Creates a deck from a title the person types. */
|
|
28
|
+
create(): void;
|
|
29
|
+
/** Imports one or more JSON files. */
|
|
30
|
+
importFiles(files: FileList | File[]): Promise<void>;
|
|
31
|
+
/** Adds back any bundled deck the store is missing. */
|
|
32
|
+
restoreStarters(): Promise<void>;
|
|
33
|
+
/** Opens the editor with no deck. */
|
|
34
|
+
scratch(): void;
|
|
35
|
+
/** Opens the settings modal. */
|
|
36
|
+
openSettings(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Renders and drives the home view. */
|
|
40
|
+
export class HomeView {
|
|
41
|
+
private readonly root: HTMLElement;
|
|
42
|
+
private readonly list: HTMLElement;
|
|
43
|
+
private readonly fileInput: HTMLInputElement;
|
|
44
|
+
private readonly actions: HomeActions;
|
|
45
|
+
|
|
46
|
+
constructor(actions: HomeActions) {
|
|
47
|
+
this.actions = actions;
|
|
48
|
+
this.root = required('#home');
|
|
49
|
+
this.list = required('#deck-list');
|
|
50
|
+
this.fileInput = required<HTMLInputElement>('#import-file');
|
|
51
|
+
this.bind();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Shows the home view and hides the editor. */
|
|
55
|
+
show(decks: StoredDeck[]): void {
|
|
56
|
+
document.querySelector('#app')?.classList.add('app--home');
|
|
57
|
+
this.root.hidden = false;
|
|
58
|
+
document.title = 'NYSDS Playground';
|
|
59
|
+
this.render(decks);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Draws the deck cards. */
|
|
63
|
+
render(decks: StoredDeck[]): void {
|
|
64
|
+
this.list.replaceChildren();
|
|
65
|
+
if (decks.length === 0) {
|
|
66
|
+
const empty = document.createElement('p');
|
|
67
|
+
empty.className = 'home__empty';
|
|
68
|
+
empty.textContent = 'No decks yet. Create one, or restore the starter decks.';
|
|
69
|
+
this.list.append(empty);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const deck of decks) {
|
|
73
|
+
this.list.append(this.card(deck));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private card(deck: StoredDeck): HTMLElement {
|
|
78
|
+
const column = document.createElement('div');
|
|
79
|
+
column.className =
|
|
80
|
+
'nys-mobile-lg:nys-grid-col-6 nys-tablet:nys-grid-col-4 nys-display-flex';
|
|
81
|
+
|
|
82
|
+
const card = document.createElement('nys-card');
|
|
83
|
+
// The heading is rendered in the default slot instead of through the
|
|
84
|
+
// `heading` property, because the property takes plain text and the title
|
|
85
|
+
// has to be a link.
|
|
86
|
+
const firstSlide = deck.slides[0]?.id ?? '';
|
|
87
|
+
const heading = document.createElement('h2');
|
|
88
|
+
heading.className = 'deck-card__title';
|
|
89
|
+
const link = document.createElement('a');
|
|
90
|
+
link.href = `?deck=${encodeURIComponent(deck.id)}&present=1#preset=${encodeURIComponent(firstSlide)}`;
|
|
91
|
+
link.textContent = deck.title;
|
|
92
|
+
heading.append(link);
|
|
93
|
+
|
|
94
|
+
const meta = document.createElement('p');
|
|
95
|
+
meta.className = 'deck-card__meta';
|
|
96
|
+
const slides = deck.slides.length === 1 ? '1 slide' : `${deck.slides.length} slides`;
|
|
97
|
+
meta.textContent = `${slides} · updated ${relativeTime(deck.updatedAt)}`;
|
|
98
|
+
card.append(heading, meta);
|
|
99
|
+
|
|
100
|
+
if (deck.description) {
|
|
101
|
+
const description = document.createElement('p');
|
|
102
|
+
description.className = 'deck-card__description';
|
|
103
|
+
description.textContent = deck.description;
|
|
104
|
+
card.append(description);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const footer = document.createElement('div');
|
|
108
|
+
footer.slot = 'footer';
|
|
109
|
+
footer.className = 'deck-card__actions';
|
|
110
|
+
footer.append(this.action('Edit', 'filled', () => this.actions.open(deck.id)));
|
|
111
|
+
footer.append(
|
|
112
|
+
...this.circle(deck.id, 'Present', PLAY_ICON, () => this.actions.present(deck.id)),
|
|
113
|
+
...this.circle(deck.id, 'Duplicate', 'content_copy', () =>
|
|
114
|
+
void this.actions.duplicate(deck.id),
|
|
115
|
+
),
|
|
116
|
+
...this.circle(deck.id, 'Export', 'download', () =>
|
|
117
|
+
void this.actions.exportDeck(deck.id),
|
|
118
|
+
),
|
|
119
|
+
...this.circle(deck.id, 'Delete', 'delete', () => this.actions.remove(deck)),
|
|
120
|
+
);
|
|
121
|
+
card.append(footer);
|
|
122
|
+
column.append(card);
|
|
123
|
+
return column;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private action(label: string, variant: string, handler: () => void): HTMLElement {
|
|
127
|
+
const button = document.createElement('nys-button');
|
|
128
|
+
button.setAttribute('label', label);
|
|
129
|
+
button.setAttribute('size', 'sm');
|
|
130
|
+
button.setAttribute('variant', variant);
|
|
131
|
+
button.addEventListener('nys-click', handler);
|
|
132
|
+
return button;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Builds one icon-only action and the tooltip that names it.
|
|
137
|
+
*
|
|
138
|
+
* `label` is the accessible name, which circle buttons render as
|
|
139
|
+
* visually hidden text, so the tooltip is the only visible hint.
|
|
140
|
+
*/
|
|
141
|
+
private circle(
|
|
142
|
+
deckId: string,
|
|
143
|
+
label: string,
|
|
144
|
+
icon: string,
|
|
145
|
+
handler: () => void,
|
|
146
|
+
): HTMLElement[] {
|
|
147
|
+
const id = `deck-${slug(deckId)}-${label.toLowerCase()}`;
|
|
148
|
+
const tooltip = document.createElement('nys-tooltip');
|
|
149
|
+
tooltip.setAttribute('for', id);
|
|
150
|
+
tooltip.setAttribute('text', label);
|
|
151
|
+
|
|
152
|
+
const button = document.createElement('nys-button');
|
|
153
|
+
button.id = id;
|
|
154
|
+
button.setAttribute('circle', '');
|
|
155
|
+
button.setAttribute('size', 'sm');
|
|
156
|
+
button.setAttribute('variant', 'outline');
|
|
157
|
+
button.setAttribute('label', label);
|
|
158
|
+
if (icon === PLAY_ICON) {
|
|
159
|
+
// NYSDS ships no play glyph, so this one comes in through the slot.
|
|
160
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
161
|
+
svg.setAttribute('slot', 'circle-icon');
|
|
162
|
+
svg.setAttribute('class', 'deck-card__play');
|
|
163
|
+
svg.setAttribute('viewBox', '0 -960 960 960');
|
|
164
|
+
svg.setAttribute('fill', 'currentColor');
|
|
165
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
166
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
167
|
+
path.setAttribute('d', 'M320-200v-560l440 280-440 280Z');
|
|
168
|
+
svg.append(path);
|
|
169
|
+
button.append(svg);
|
|
170
|
+
} else {
|
|
171
|
+
button.setAttribute('icon', icon);
|
|
172
|
+
}
|
|
173
|
+
button.addEventListener('nys-click', handler);
|
|
174
|
+
return [tooltip, button];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private bind(): void {
|
|
178
|
+
on('#new-deck-button', () => this.actions.create());
|
|
179
|
+
on('#import-deck-button', () => this.fileInput.click());
|
|
180
|
+
on('#scratch-button', () => this.actions.scratch());
|
|
181
|
+
on('#restore-starters-button', () => void this.actions.restoreStarters());
|
|
182
|
+
on('#home-settings-button', () => this.actions.openSettings());
|
|
183
|
+
|
|
184
|
+
this.fileInput.addEventListener('change', () => {
|
|
185
|
+
const files = this.fileInput.files;
|
|
186
|
+
if (files && files.length > 0) {
|
|
187
|
+
void this.actions.importFiles(files);
|
|
188
|
+
}
|
|
189
|
+
// Clear the value so choosing the same file twice still fires.
|
|
190
|
+
this.fileInput.value = '';
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Dropping a deck file anywhere on the home view imports it.
|
|
194
|
+
const stop = (event: DragEvent): void => {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
};
|
|
197
|
+
this.root.addEventListener('dragover', (event) => {
|
|
198
|
+
stop(event);
|
|
199
|
+
this.root.classList.add('home--dragging');
|
|
200
|
+
});
|
|
201
|
+
this.root.addEventListener('dragleave', () => {
|
|
202
|
+
this.root.classList.remove('home--dragging');
|
|
203
|
+
});
|
|
204
|
+
this.root.addEventListener('drop', (event) => {
|
|
205
|
+
stop(event);
|
|
206
|
+
this.root.classList.remove('home--dragging');
|
|
207
|
+
const files = event.dataTransfer?.files;
|
|
208
|
+
if (files && files.length > 0) {
|
|
209
|
+
void this.actions.importFiles(files);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function on(selector: string, handler: () => void): void {
|
|
216
|
+
document.querySelector(selector)?.addEventListener('nys-click', handler);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function required<T extends HTMLElement = HTMLElement>(selector: string): T {
|
|
220
|
+
const element = document.querySelector<T>(selector);
|
|
221
|
+
if (!element) {
|
|
222
|
+
throw new Error(`The page is missing the element "${selector}".`);
|
|
223
|
+
}
|
|
224
|
+
return element;
|
|
225
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The icon names that ship with NYSDS.
|
|
3
|
+
*
|
|
4
|
+
* NYSDS bundles a curated subset of Material Symbols, not the whole set. An
|
|
5
|
+
* icon name outside this list renders as empty space. Source:
|
|
6
|
+
* https://designsystem.ny.gov/components/icon/ and the icon library in
|
|
7
|
+
* @nysds/components 1.21.0. Update this list when a release adds icons.
|
|
8
|
+
*/
|
|
9
|
+
export const NYSDS_ICON_NAMES: ReadonlySet<string> = new Set([
|
|
10
|
+
'ac_unit',
|
|
11
|
+
'account_balance_filled',
|
|
12
|
+
'account_circle',
|
|
13
|
+
'add',
|
|
14
|
+
'air',
|
|
15
|
+
'arrow_back',
|
|
16
|
+
'arrow_downward',
|
|
17
|
+
'arrow_forward',
|
|
18
|
+
'arrow_upward',
|
|
19
|
+
'attach_file',
|
|
20
|
+
'calendar_month',
|
|
21
|
+
'cancel',
|
|
22
|
+
'cancel_filled',
|
|
23
|
+
'check',
|
|
24
|
+
'check_circle',
|
|
25
|
+
'chevron_down',
|
|
26
|
+
'chevron_left',
|
|
27
|
+
'chevron_right',
|
|
28
|
+
'chevron_up',
|
|
29
|
+
'clear_day',
|
|
30
|
+
'close',
|
|
31
|
+
'code',
|
|
32
|
+
'content_copy',
|
|
33
|
+
'coronavirus',
|
|
34
|
+
'delete',
|
|
35
|
+
'download',
|
|
36
|
+
'download_done',
|
|
37
|
+
'drive_folder_upload',
|
|
38
|
+
'edit_square',
|
|
39
|
+
'emergency_home',
|
|
40
|
+
'error',
|
|
41
|
+
'expand_all',
|
|
42
|
+
'filter_alt',
|
|
43
|
+
'filter_list',
|
|
44
|
+
'height',
|
|
45
|
+
'help',
|
|
46
|
+
'info',
|
|
47
|
+
'language',
|
|
48
|
+
'language_filled',
|
|
49
|
+
'link',
|
|
50
|
+
'location_on',
|
|
51
|
+
'lock_filled',
|
|
52
|
+
'mail',
|
|
53
|
+
'menu',
|
|
54
|
+
'more_vert',
|
|
55
|
+
'notifications',
|
|
56
|
+
'open_in_new',
|
|
57
|
+
'phone_in_talk',
|
|
58
|
+
'print',
|
|
59
|
+
'progress_activity',
|
|
60
|
+
'publish',
|
|
61
|
+
'rainy',
|
|
62
|
+
'refresh',
|
|
63
|
+
'remove',
|
|
64
|
+
'schedule',
|
|
65
|
+
'search',
|
|
66
|
+
'share',
|
|
67
|
+
'sms',
|
|
68
|
+
'social_bluesky',
|
|
69
|
+
'social_facebook',
|
|
70
|
+
'social_flickr',
|
|
71
|
+
'social_google_play',
|
|
72
|
+
'social_instagram',
|
|
73
|
+
'social_linkedin',
|
|
74
|
+
'social_pinterest',
|
|
75
|
+
'social_rss',
|
|
76
|
+
'social_snapchat',
|
|
77
|
+
'social_soundcloud',
|
|
78
|
+
'social_threads',
|
|
79
|
+
'social_tiktok',
|
|
80
|
+
'social_tumblr',
|
|
81
|
+
'social_vimeo',
|
|
82
|
+
'social_x',
|
|
83
|
+
'social_youtube',
|
|
84
|
+
'sort',
|
|
85
|
+
'straight',
|
|
86
|
+
'thumb_down',
|
|
87
|
+
'thumb_up',
|
|
88
|
+
'upload_file',
|
|
89
|
+
'visibility',
|
|
90
|
+
'visibility_off',
|
|
91
|
+
'warning',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/** Attribute names whose values are icon names. */
|
|
95
|
+
export const ICON_ATTRIBUTES = ['icon', 'prefixIcon', 'suffixIcon'] as const;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Finds icon names used in a chunk of HTML: `icon`, `prefixIcon`, and
|
|
99
|
+
* `suffixIcon` attributes on any element, plus `name` on `<nys-icon>`.
|
|
100
|
+
*/
|
|
101
|
+
export function findIconNames(html: string): string[] {
|
|
102
|
+
const found: string[] = [];
|
|
103
|
+
const attrPattern = /\b(?:icon|prefixIcon|suffixIcon)\s*=\s*"([^"]*)"/gi;
|
|
104
|
+
for (const match of html.matchAll(attrPattern)) {
|
|
105
|
+
found.push(match[1]);
|
|
106
|
+
}
|
|
107
|
+
const nysIconPattern = /<nys-icon\b[^>]*\bname\s*=\s*"([^"]*)"/gi;
|
|
108
|
+
for (const match of html.matchAll(nysIconPattern)) {
|
|
109
|
+
found.push(match[1]);
|
|
110
|
+
}
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Returns the icon names in `html` that NYSDS does not ship. */
|
|
115
|
+
export function unknownIconNames(html: string): string[] {
|
|
116
|
+
return findIconNames(html).filter((name) => name !== '' && !NYSDS_ICON_NAMES.has(name));
|
|
117
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fails when the shell, a preset, or a deck uses an icon NYSDS doesn't ship.
|
|
3
|
+
*
|
|
4
|
+
* A missing icon renders as empty space, which looks broken on a projector.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import {readFileSync, readdirSync} from 'node:fs';
|
|
8
|
+
import {join} from 'node:path';
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
|
|
11
|
+
import {findIconNames, unknownIconNames} from './icon-names.ts';
|
|
12
|
+
|
|
13
|
+
const ROOT = join(import.meta.dirname, '..');
|
|
14
|
+
|
|
15
|
+
/** Reads every `.json` file in a directory and yields its name and text. */
|
|
16
|
+
function jsonFiles(dir: string): Array<{name: string; text: string}> {
|
|
17
|
+
return readdirSync(join(ROOT, dir))
|
|
18
|
+
.filter((file) => file.endsWith('.json'))
|
|
19
|
+
.map((file) => ({name: `${dir}/${file}`, text: readFileSync(join(ROOT, dir, file), 'utf8')}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Pulls every string value out of parsed JSON, however deeply nested. */
|
|
23
|
+
function stringValues(value: unknown): string[] {
|
|
24
|
+
if (typeof value === 'string') {
|
|
25
|
+
return [value];
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.flatMap(stringValues);
|
|
29
|
+
}
|
|
30
|
+
if (value && typeof value === 'object') {
|
|
31
|
+
return Object.values(value).flatMap(stringValues);
|
|
32
|
+
}
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('findIconNames reads icon attributes and nys-icon names', () => {
|
|
37
|
+
const html =
|
|
38
|
+
'<nys-button prefixIcon="refresh"></nys-button>' +
|
|
39
|
+
'<nys-button circle icon="close" label="x"></nys-button>' +
|
|
40
|
+
'<nys-icon name="info"></nys-icon>' +
|
|
41
|
+
'<nys-checkbox name="terms"></nys-checkbox>';
|
|
42
|
+
assert.deepEqual(findIconNames(html), ['refresh', 'close', 'info']);
|
|
43
|
+
assert.deepEqual(unknownIconNames('<nys-button prefixIcon="restart_alt">'), ['restart_alt']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('index.html uses only NYSDS icons', () => {
|
|
47
|
+
const html = readFileSync(join(ROOT, 'index.html'), 'utf8');
|
|
48
|
+
assert.deepEqual(unknownIconNames(html), []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
for (const dir of ['presets', 'decks']) {
|
|
52
|
+
for (const file of jsonFiles(dir)) {
|
|
53
|
+
test(`${file.name} uses only NYSDS icons`, () => {
|
|
54
|
+
const markup = stringValues(JSON.parse(file.text)).join('\n');
|
|
55
|
+
assert.deepEqual(unknownIconNames(markup), []);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key routing for presentation mode.
|
|
3
|
+
*
|
|
4
|
+
* The presenter edits code on the slide, so plain arrow keys have to reach the
|
|
5
|
+
* editor. These helpers decide when a key means "change slide" and when it
|
|
6
|
+
* means "move the caret". They take plain data so `src/state.test.ts` can
|
|
7
|
+
* exercise them without a DOM.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {PaneId} from './preset-schema';
|
|
11
|
+
|
|
12
|
+
/** Asks presentation mode to collapse or expand one editor column. */
|
|
13
|
+
export type TogglePaneAction = `toggle-pane:${PaneId}`;
|
|
14
|
+
|
|
15
|
+
/** What a key press asks presentation mode to do. */
|
|
16
|
+
export type PresentationAction =
|
|
17
|
+
| 'next'
|
|
18
|
+
| 'prev'
|
|
19
|
+
| 'first'
|
|
20
|
+
| 'last'
|
|
21
|
+
| 'toggle-code'
|
|
22
|
+
| 'toggle-notes'
|
|
23
|
+
| 'toggle-theme'
|
|
24
|
+
| 'toggle-layout'
|
|
25
|
+
| TogglePaneAction
|
|
26
|
+
| 'exit';
|
|
27
|
+
|
|
28
|
+
/** The column each number key toggles in the side-by-side layout. */
|
|
29
|
+
const PANE_KEYS: Record<string, PaneId> = {'1': 'html', '2': 'css', '3': 'js'};
|
|
30
|
+
|
|
31
|
+
/** The parts of a `KeyboardEvent` that routing depends on. */
|
|
32
|
+
export interface KeyLike {
|
|
33
|
+
key: string;
|
|
34
|
+
altKey: boolean;
|
|
35
|
+
ctrlKey: boolean;
|
|
36
|
+
metaKey: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reports whether a key press asks for an immediate preview rebuild.
|
|
41
|
+
*
|
|
42
|
+
* `Cmd/Ctrl+Enter` and `Cmd/Ctrl+S` work everywhere, including mid-edit, so the
|
|
43
|
+
* presenter never has to leave the editor to see a change.
|
|
44
|
+
*/
|
|
45
|
+
export function isBuildShortcut(event: KeyLike): boolean {
|
|
46
|
+
if (!event.ctrlKey && !event.metaKey) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return event.key === 'Enter' || event.key === 's' || event.key === 'S';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Elements whose key presses belong to the person typing, not to the deck. */
|
|
53
|
+
const TYPING_TAGS = new Set([
|
|
54
|
+
'playground-file-editor',
|
|
55
|
+
'playground-code-editor',
|
|
56
|
+
'input',
|
|
57
|
+
'textarea',
|
|
58
|
+
'select',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reports whether a key press landed in something the presenter is typing in.
|
|
63
|
+
*
|
|
64
|
+
* Pass the lowercase tag names from the event's `composedPath()`, which
|
|
65
|
+
* crosses shadow roots and so sees the CodeMirror editor inside
|
|
66
|
+
* `playground-file-editor`.
|
|
67
|
+
*/
|
|
68
|
+
export function isTypingContext(pathTagNames: readonly string[]): boolean {
|
|
69
|
+
return pathTagNames.some((tag) => TYPING_TAGS.has(tag));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Maps `ArrowRight` and friends to a step, or returns `null`. */
|
|
73
|
+
function stepFor(key: string): PresentationAction | null {
|
|
74
|
+
switch (key) {
|
|
75
|
+
case 'ArrowRight':
|
|
76
|
+
case 'ArrowDown':
|
|
77
|
+
return 'next';
|
|
78
|
+
case 'ArrowLeft':
|
|
79
|
+
case 'ArrowUp':
|
|
80
|
+
return 'prev';
|
|
81
|
+
default:
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decides what a key press does.
|
|
88
|
+
*
|
|
89
|
+
* `Alt` plus an arrow steps the deck from anywhere, including mid-edit. Every
|
|
90
|
+
* other shortcut is ignored while the presenter is typing, so the editor keeps
|
|
91
|
+
* its own arrow keys, space bar, and Escape.
|
|
92
|
+
*
|
|
93
|
+
* Set `columns` when the side-by-side layout is on. The number keys toggle a
|
|
94
|
+
* column only then, so they stay free otherwise.
|
|
95
|
+
*/
|
|
96
|
+
export function routeKey(
|
|
97
|
+
event: KeyLike,
|
|
98
|
+
typing: boolean,
|
|
99
|
+
columns = false,
|
|
100
|
+
): PresentationAction | null {
|
|
101
|
+
if (event.ctrlKey || event.metaKey) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (event.altKey) {
|
|
105
|
+
return stepFor(event.key);
|
|
106
|
+
}
|
|
107
|
+
if (typing) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const step = stepFor(event.key);
|
|
111
|
+
if (step) {
|
|
112
|
+
return step;
|
|
113
|
+
}
|
|
114
|
+
switch (event.key) {
|
|
115
|
+
case 'PageDown':
|
|
116
|
+
case ' ':
|
|
117
|
+
return 'next';
|
|
118
|
+
case 'PageUp':
|
|
119
|
+
return 'prev';
|
|
120
|
+
case 'Home':
|
|
121
|
+
return 'first';
|
|
122
|
+
case 'End':
|
|
123
|
+
return 'last';
|
|
124
|
+
case 'Escape':
|
|
125
|
+
return 'exit';
|
|
126
|
+
case 'c':
|
|
127
|
+
case 'C':
|
|
128
|
+
return 'toggle-code';
|
|
129
|
+
case 'n':
|
|
130
|
+
case 'N':
|
|
131
|
+
return 'toggle-notes';
|
|
132
|
+
case 't':
|
|
133
|
+
case 'T':
|
|
134
|
+
return 'toggle-theme';
|
|
135
|
+
case 'e':
|
|
136
|
+
case 'E':
|
|
137
|
+
return 'toggle-layout';
|
|
138
|
+
default:
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const pane = columns ? PANE_KEYS[event.key] : undefined;
|
|
142
|
+
return pane ? `toggle-pane:${pane}` : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Builds the on-screen hint.
|
|
147
|
+
*
|
|
148
|
+
* Notes are left out when the slide has none, and the column shortcuts only
|
|
149
|
+
* appear in the side-by-side layout.
|
|
150
|
+
*/
|
|
151
|
+
export function hintText(hasNotes: boolean, columns: boolean): string {
|
|
152
|
+
const parts = ['← → or Alt+← → to change slides', 'c code', 'e layout'];
|
|
153
|
+
if (columns) {
|
|
154
|
+
parts.push('1 2 3 panes');
|
|
155
|
+
}
|
|
156
|
+
parts.push('t theme');
|
|
157
|
+
if (hasNotes) {
|
|
158
|
+
parts.push('n notes');
|
|
159
|
+
}
|
|
160
|
+
parts.push('Esc exit');
|
|
161
|
+
return parts.join(' · ');
|
|
162
|
+
}
|