@nysds/playground 0.1.0 → 0.2.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/src/main.ts CHANGED
@@ -11,6 +11,8 @@ import type {PlaygroundProject} from 'playground-elements/playground-project.js'
11
11
  import type {Slide, StoredDeck} from './deck-model';
12
12
  import {
13
13
  BLANK_SLIDE_HTML,
14
+ arrangeSlides,
15
+ hasContent,
14
16
  getSlide,
15
17
  makeSlide,
16
18
  nextSlideId,
@@ -24,6 +26,7 @@ import {needsReboot, routeFor, slideIdFromHash} from './routing';
24
26
  import {PLAYGROUND_CONFIG, componentsUrl, stylesUrl} from './playground.config';
25
27
  import {PlaygroundHost} from './playground';
26
28
  import {Presentation} from './present';
29
+ import {SlideList} from './slide-list';
27
30
  import type {PlaygroundState} from './state';
28
31
  import {
29
32
  debounce,
@@ -60,8 +63,13 @@ import {isPrerelease, loadVersions, resolveVersion} from './versions';
60
63
  /** How long to wait after a keystroke before writing the URL. */
61
64
  const HASH_DEBOUNCE_MS = 300;
62
65
 
63
- /** How long a toast stays on screen, in milliseconds. */
64
- const TOAST_DURATION_MS = 2600;
66
+ /**
67
+ * How long a toast stays on screen, in milliseconds.
68
+ *
69
+ * Long enough to read two short sentences. The timer pauses while the
70
+ * pointer or focus is on the toast, and the toast can be dismissed sooner.
71
+ */
72
+ const TOAST_DURATION_MS = 6000;
65
73
 
66
74
  /** The name every new deck starts with, until someone types over it. */
67
75
  export const UNTITLED_DECK = 'Untitled';
@@ -141,7 +149,10 @@ class PlaygroundApp {
141
149
  private fontSize: FontSize = initialFontSize();
142
150
  private updateMode: UpdateMode = initialUpdateMode();
143
151
  private theme: EditorTheme = initialTheme();
152
+ private readonly slideList: SlideList;
144
153
  private toastTimer: number | undefined;
154
+ /** The element that had focus when the toast appeared. */
155
+ private toastOpener: Element | null = null;
145
156
  private leaveListeners: AbortController | undefined;
146
157
  private presentation: Presentation | undefined;
147
158
 
@@ -175,6 +186,17 @@ class PlaygroundApp {
175
186
  this.buildButton = required('#build-button');
176
187
  this.savedIndicator = required('#saved-indicator');
177
188
  this.toast = required('#toast');
189
+ this.slideList = new SlideList(required('#slide-list'), required('#slide-list-status'));
190
+ // A toast waits while someone is reading it with the pointer or has
191
+ // tabbed to its close button.
192
+ this.toast.addEventListener('mouseenter', () => this.clearToastTimer());
193
+ this.toast.addEventListener('mouseleave', () => this.resumeToastTimer());
194
+ this.toast.addEventListener('focusin', () => this.clearToastTimer());
195
+ this.toast.addEventListener('focusout', (event) => {
196
+ if (!this.toast.contains(event.relatedTarget as Node | null)) {
197
+ this.resumeToastTimer();
198
+ }
199
+ });
178
200
  this.openedWith = {html: initial.html, css: initial.css, js: initial.js};
179
201
  this.host = new PlaygroundHost(project, initial, deck.baseCss);
180
202
  this.host.setUpdateMode(this.updateMode);
@@ -307,8 +329,6 @@ class PlaygroundApp {
307
329
 
308
330
  bindClick('#add-slide-button', () => void this.addSlide());
309
331
  bindClick('#slide-settings-button', () => this.openSlideSettings());
310
- bindClick('#move-slide-back-button', () => void this.moveSlide(-1));
311
- bindClick('#move-slide-forward-button', () => void this.moveSlide(1));
312
332
  bindClick('#duplicate-slide-button', () => void this.duplicateSlide());
313
333
  bindClick('#delete-slide-button', () => this.deleteSlide());
314
334
  bindClick('#slide-modal-done', () => {
@@ -510,31 +530,21 @@ class PlaygroundApp {
510
530
  private async addSlide(): Promise<void> {
511
531
  await this.flushSave();
512
532
  const title = `Slide ${this.deck.slides.length + 1}`;
533
+ // A new deck's first slide shows the Excelsior button, so there is something
534
+ // to see. A slide added to a deck that is already going starts empty, so
535
+ // there is nothing to clear away first.
513
536
  const slide = makeSlide({
514
537
  id: nextSlideId(title, this.deck.slides),
515
538
  title,
516
- html: BLANK_SLIDE_HTML,
517
539
  });
518
540
  const at = slideIndex(this.deck, this.activePresetId);
519
541
  const slides = [...this.deck.slides];
520
542
  slides.splice(at === -1 ? slides.length : at + 1, 0, slide);
521
543
  await this.updateDeck({...this.deck, slides});
522
544
  this.loadPreset(slide);
523
- this.openSlideSettings();
524
- }
525
-
526
- /** Moves the current slide one place earlier or later. */
527
- private async moveSlide(delta: number): Promise<void> {
528
- await this.flushSave();
529
- const from = slideIndex(this.deck, this.activePresetId);
530
- const to = from + delta;
531
- if (from === -1 || to < 0 || to >= this.deck.slides.length) {
532
- return;
533
- }
534
- const slides = [...this.deck.slides];
535
- const [slide] = slides.splice(from, 1);
536
- slides.splice(to, 0, slide!);
537
- await this.updateDeck({...this.deck, slides});
545
+ // The slide opens ready to code. A toast points at Slide settings for the
546
+ // title and notes, rather than a modal standing in the way.
547
+ this.showToast('success', `${title} added`, 'Name it and add notes in Slide settings.');
538
548
  }
539
549
 
540
550
  /** Copies the current slide in place. */
@@ -562,7 +572,7 @@ class PlaygroundApp {
562
572
  this.showToast('warning', 'Keep one slide', 'A deck needs at least one slide.');
563
573
  return;
564
574
  }
565
- confirmAction(`Delete the slide "${current.title}"? This cannot be undone.`, async () => {
575
+ const remove = async () => {
566
576
  this.saveSoon.cancel();
567
577
  const index = slideIndex(this.deck, current.id);
568
578
  const slides = this.deck.slides.filter((slide) => slide.id !== current.id);
@@ -575,7 +585,16 @@ class PlaygroundApp {
575
585
  this.loadPreset(next);
576
586
  }
577
587
  this.presentation?.refresh();
578
- });
588
+ };
589
+ // A blank slide has nothing to lose, so it goes without a question. The
590
+ // editors may hold something not yet saved, so check what is on screen.
591
+ const onScreen = this.host.getState();
592
+ const asEdited = {...current, html: onScreen.html, css: onScreen.css, js: onScreen.js};
593
+ if (hasContent(asEdited)) {
594
+ confirmAction(`Delete the slide "${current.title}"? This cannot be undone.`, remove);
595
+ } else {
596
+ void remove();
597
+ }
579
598
  }
580
599
 
581
600
  /** Opens the deck settings modal, which also renames the deck. */
@@ -584,6 +603,7 @@ class PlaygroundApp {
584
603
  setFieldValue('#deck-title-input', this.deck.title);
585
604
  setFieldValue('#deck-description-input', this.deck.description);
586
605
  setFieldValue('#deck-base-css-input', this.deck.baseCss);
606
+ this.slideList.load(this.deck.slides);
587
607
  modal.open = true;
588
608
  }
589
609
 
@@ -591,13 +611,26 @@ class PlaygroundApp {
591
611
  if (!this.hasDeck) {
592
612
  return;
593
613
  }
614
+ // Write the editors out first, so a slide the list removed cannot take
615
+ // the current editor contents with it.
616
+ await this.flushSave();
594
617
  const title = fieldValue('#deck-title-input').trim() || this.deck.title;
618
+ const slides = arrangeSlides(this.deck.slides, this.slideList.value());
619
+ const activeSurvives = slides.some((slide) => slide.id === this.activePresetId);
620
+ if (!activeSurvives) {
621
+ this.saveSoon.cancel();
622
+ this.activePresetId = slides[0]?.id ?? null;
623
+ }
595
624
  await this.updateDeck({
596
625
  ...this.deck,
597
626
  title,
598
627
  description: fieldValue('#deck-description-input'),
599
628
  baseCss: fieldValue('#deck-base-css-input'),
629
+ slides,
600
630
  });
631
+ if (!activeSurvives && slides[0]) {
632
+ this.loadPreset(slides[0]);
633
+ }
601
634
  // The base CSS lives in the hidden head, so the preview has to be rebuilt.
602
635
  this.host.load(this.currentState(), this.deck.baseCss);
603
636
  }
@@ -1041,21 +1074,52 @@ class PlaygroundApp {
1041
1074
  });
1042
1075
  }
1043
1076
 
1077
+ /**
1078
+ * Shows a toast and lets screen readers announce it.
1079
+ *
1080
+ * The container is a live region that is always in the page, so inserting
1081
+ * the alert is the change that gets announced. Warnings use the alert's own
1082
+ * assertive region as well. The toast goes away on its own, when its close
1083
+ * button is used, and dismissing it hands focus back to the control that
1084
+ * raised it.
1085
+ */
1044
1086
  private showToast(type: string, heading: string, text: string): void {
1045
1087
  const alert = document.createElement('nys-alert');
1046
1088
  alert.setAttribute('type', type);
1047
1089
  alert.setAttribute('heading', heading);
1048
1090
  alert.setAttribute('text', text);
1049
1091
  alert.setAttribute('dismissible', '');
1092
+ alert.addEventListener('nys-close', () => this.hideToast());
1093
+ this.toastOpener = document.activeElement;
1050
1094
  this.toast.replaceChildren(alert);
1051
- this.toast.hidden = false;
1095
+ this.resumeToastTimer();
1096
+ }
1097
+
1098
+ /** Removes the toast. Focus goes back where it came from if it was inside. */
1099
+ private hideToast(): void {
1100
+ this.clearToastTimer();
1101
+ const hadFocus = this.toast.contains(document.activeElement);
1102
+ this.toast.replaceChildren();
1103
+ const opener = this.toastOpener;
1104
+ this.toastOpener = null;
1105
+ if (hadFocus && opener instanceof HTMLElement && opener.isConnected) {
1106
+ opener.focus();
1107
+ }
1108
+ }
1109
+
1110
+ private clearToastTimer(): void {
1052
1111
  if (this.toastTimer !== undefined) {
1053
1112
  window.clearTimeout(this.toastTimer);
1113
+ this.toastTimer = undefined;
1114
+ }
1115
+ }
1116
+
1117
+ /** Starts the countdown over, if there is a toast to count down. */
1118
+ private resumeToastTimer(): void {
1119
+ this.clearToastTimer();
1120
+ if (this.toast.childElementCount > 0) {
1121
+ this.toastTimer = window.setTimeout(() => this.hideToast(), TOAST_DURATION_MS);
1054
1122
  }
1055
- this.toastTimer = window.setTimeout(() => {
1056
- this.toast.hidden = true;
1057
- this.toast.replaceChildren();
1058
- }, TOAST_DURATION_MS);
1059
1123
  }
1060
1124
  }
1061
1125
 
package/src/present.ts CHANGED
@@ -48,6 +48,8 @@ export class Presentation {
48
48
  private readonly notes: HTMLElement;
49
49
  private readonly notesBody: HTMLElement;
50
50
  private readonly notesButton: HTMLElement;
51
+ private readonly prevButton: HTMLElement & {disabled?: boolean};
52
+ private readonly nextButton: HTMLElement & {disabled?: boolean};
51
53
  private readonly panes: EditorPanes;
52
54
  private collapsed = false;
53
55
  private notesOpen = false;
@@ -69,6 +71,8 @@ export class Presentation {
69
71
  this.notes = required(root, '#notes');
70
72
  this.notesBody = required(root, '#notes-body');
71
73
  this.notesButton = required(root, '#notes-button');
74
+ this.prevButton = required(root, '#prev-button');
75
+ this.nextButton = required(root, '#next-button');
72
76
  }
73
77
 
74
78
  /** Binds the slide bar, then enters presentation mode when asked. */
@@ -150,6 +154,11 @@ export class Presentation {
150
154
  for (const element of document.querySelectorAll<HTMLElement>('[data-count]')) {
151
155
  element.textContent = counter;
152
156
  }
157
+ // The arrows go quiet at the ends of the deck. From a shared code link,
158
+ // where no slide is current, both still work: forward starts the deck and
159
+ // back lands on its last slide.
160
+ this.prevButton.disabled = deck.slides.length === 0 || index === 0;
161
+ this.nextButton.disabled = deck.slides.length === 0 || index === deck.slides.length - 1;
153
162
 
154
163
  const notes = preset?.notes ?? '';
155
164
  this.notesBody.textContent = notes;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * The slide list inside Deck settings.
3
+ *
4
+ * One row per slide: a drag handle, the position, the title and group as
5
+ * fields, and Move up, Move down, and Remove. Everything stays in the list
6
+ * until Deck settings is applied, so a removal can be undone with Restore, and
7
+ * closing the modal without Done discards the lot.
8
+ *
9
+ * Dragging is for the pointer; the arrows do the same job from the keyboard,
10
+ * and a live region reads out each move.
11
+ */
12
+ import type {Slide, SlideEdit} from './deck-model';
13
+
14
+ type Control = 'handle' | 'up' | 'down' | 'remove' | 'restore';
15
+
16
+ export class SlideList {
17
+ private rows: SlideEdit[] = [];
18
+ private dragId: string | null = null;
19
+
20
+ constructor(
21
+ private readonly list: HTMLElement,
22
+ private readonly status: HTMLElement,
23
+ ) {
24
+ list.addEventListener('dragover', (event) => this.onDragOver(event));
25
+ list.addEventListener('dragleave', (event) => {
26
+ if (!(event.relatedTarget instanceof Node) || !list.contains(event.relatedTarget)) {
27
+ this.clearDropMarks();
28
+ }
29
+ });
30
+ list.addEventListener('drop', (event) => this.onDrop(event));
31
+ }
32
+
33
+ /** Fills the list from the deck's slides, discarding any earlier edits. */
34
+ load(slides: readonly Slide[]): void {
35
+ this.rows = slides.map((slide) => ({id: slide.id, title: slide.title, group: slide.group}));
36
+ this.status.textContent = '';
37
+ this.render();
38
+ }
39
+
40
+ /** The rows as edited, in their current order. */
41
+ value(): SlideEdit[] {
42
+ return this.rows.map((row) => ({...row}));
43
+ }
44
+
45
+ private get kept(): number {
46
+ return this.rows.filter((row) => !row.removed).length;
47
+ }
48
+
49
+ private render(focus?: {id: string; control: Control}): void {
50
+ this.list.replaceChildren(...this.rows.map((row, index) => this.row(row, index)));
51
+ if (focus) {
52
+ const selector = `[data-id="${CSS.escape(focus.id)}"] [data-control="${focus.control}"]`;
53
+ const host = this.list.querySelector<HTMLElement & {updateComplete?: Promise<unknown>}>(
54
+ selector,
55
+ );
56
+ // The design system button does not delegate focus to its inner button,
57
+ // and that button only exists once the element has rendered.
58
+ void host?.updateComplete?.then(() => {
59
+ (host.shadowRoot?.querySelector<HTMLElement>('button') ?? host).focus();
60
+ });
61
+ }
62
+ }
63
+
64
+ private row(row: SlideEdit, index: number): HTMLElement {
65
+ const item = document.createElement('li');
66
+ item.className = row.removed ? 'slide-row slide-row--removed' : 'slide-row';
67
+ item.dataset.id = row.id;
68
+
69
+ // The handle is a button like the row's other controls. The pointer drags
70
+ // it; from the keyboard, the up and down arrow keys move the row while it
71
+ // has focus.
72
+ const handle = this.circle('handle', 'menu', 'Drag to reorder', !!row.removed, () => undefined);
73
+ handle.classList.add('slide-row__handle');
74
+ handle.draggable = !row.removed;
75
+ handle.addEventListener('keydown', (event) => {
76
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
77
+ event.preventDefault();
78
+ this.move(row, event.key === 'ArrowUp' ? -1 : 1, 'handle');
79
+ }
80
+ });
81
+ handle.addEventListener('dragstart', (event) => {
82
+ this.dragId = row.id;
83
+ item.classList.add('slide-row--dragging');
84
+ if (event.dataTransfer) {
85
+ event.dataTransfer.setData('text/plain', row.id);
86
+ event.dataTransfer.effectAllowed = 'move';
87
+ event.dataTransfer.setDragImage(item, 24, 24);
88
+ }
89
+ });
90
+ handle.addEventListener('dragend', () => {
91
+ this.dragId = null;
92
+ item.classList.remove('slide-row--dragging');
93
+ this.clearDropMarks();
94
+ });
95
+
96
+ const position = document.createElement('span');
97
+ position.className = 'slide-row__position';
98
+ position.textContent = String(index + 1);
99
+
100
+ const title = this.field('slide-row__title', `Slide ${index + 1} title`, row.title, (value) => {
101
+ row.title = value;
102
+ });
103
+ const group = this.field('slide-row__group', `Slide ${index + 1} group`, row.group, (value) => {
104
+ row.group = value;
105
+ });
106
+ group.setAttribute('placeholder', 'Group');
107
+ group.setAttribute('width', 'md');
108
+ if (row.removed) {
109
+ title.setAttribute('disabled', '');
110
+ group.setAttribute('disabled', '');
111
+ }
112
+
113
+ const actions = document.createElement('div');
114
+ actions.className = 'slide-row__actions';
115
+ if (row.removed) {
116
+ const restore = document.createElement('nys-button');
117
+ restore.dataset.control = 'restore';
118
+ restore.setAttribute('size', 'sm');
119
+ restore.setAttribute('variant', 'ghost');
120
+ restore.setAttribute('label', 'Restore');
121
+ restore.addEventListener('nys-click', () => this.restore(row));
122
+ actions.append(restore);
123
+ } else {
124
+ actions.append(
125
+ this.circle('up', 'arrow_upward', 'Move up', index === 0, () => this.move(row, -1)),
126
+ this.circle('down', 'arrow_downward', 'Move down', index === this.rows.length - 1, () =>
127
+ this.move(row, 1),
128
+ ),
129
+ this.circle('remove', 'delete', 'Remove', this.kept === 1, () => this.remove(row)),
130
+ );
131
+ }
132
+
133
+ item.append(handle, position, title, group, actions);
134
+ return item;
135
+ }
136
+
137
+ private field(
138
+ className: string,
139
+ label: string,
140
+ value: string,
141
+ onInput: (value: string) => void,
142
+ ): HTMLElement {
143
+ const input = document.createElement('nys-textinput');
144
+ input.className = className;
145
+ input.setAttribute('arialabel', label);
146
+ input.setAttribute('value', value);
147
+ input.addEventListener('nys-input', (event) => {
148
+ onInput((event as CustomEvent<{value: string}>).detail.value);
149
+ });
150
+ return input;
151
+ }
152
+
153
+ private circle(
154
+ control: Control,
155
+ icon: string,
156
+ label: string,
157
+ disabled: boolean,
158
+ handler: () => void,
159
+ ): HTMLElement {
160
+ const button = document.createElement('nys-button');
161
+ button.dataset.control = control;
162
+ button.setAttribute('size', 'sm');
163
+ button.setAttribute('variant', 'ghost');
164
+ button.setAttribute('circle', '');
165
+ button.setAttribute('icon', icon);
166
+ button.setAttribute('label', label);
167
+ if (disabled) {
168
+ button.setAttribute('disabled', '');
169
+ }
170
+ button.addEventListener('nys-click', handler);
171
+ return button;
172
+ }
173
+
174
+ private move(row: SlideEdit, delta: number, from_control: 'arrow' | 'handle' = 'arrow'): void {
175
+ const from = this.rows.indexOf(row);
176
+ const to = from + delta;
177
+ if (from === -1 || to < 0 || to >= this.rows.length) {
178
+ return;
179
+ }
180
+ this.rows.splice(from, 1);
181
+ this.rows.splice(to, 0, row);
182
+ // Focus stays on the control that was used, unless the row has reached an
183
+ // end and that arrow is now disabled.
184
+ const arrow: Control =
185
+ delta < 0 ? (to === 0 ? 'down' : 'up') : to === this.rows.length - 1 ? 'up' : 'down';
186
+ this.render({id: row.id, control: from_control === 'handle' ? 'handle' : arrow});
187
+ this.announce(`Moved "${this.name(row)}" to position ${to + 1} of ${this.rows.length}.`);
188
+ }
189
+
190
+ private remove(row: SlideEdit): void {
191
+ if (this.kept === 1) {
192
+ return;
193
+ }
194
+ row.removed = true;
195
+ this.render({id: row.id, control: 'restore'});
196
+ this.announce(`"${this.name(row)}" goes when you select Done. Select Restore to keep it.`);
197
+ }
198
+
199
+ private restore(row: SlideEdit): void {
200
+ row.removed = false;
201
+ this.render({id: row.id, control: 'remove'});
202
+ this.announce(`Restored "${this.name(row)}".`);
203
+ }
204
+
205
+ private name(row: SlideEdit): string {
206
+ return row.title.trim() || 'Untitled slide';
207
+ }
208
+
209
+ /** Reads a message to screen readers, even when it repeats the last one. */
210
+ private announce(text: string): void {
211
+ this.status.textContent = '';
212
+ window.requestAnimationFrame(() => {
213
+ this.status.textContent = text;
214
+ });
215
+ }
216
+
217
+ private onDragOver(event: DragEvent): void {
218
+ if (!this.dragId) {
219
+ return;
220
+ }
221
+ const target = this.rowAt(event);
222
+ if (!target) {
223
+ return;
224
+ }
225
+ event.preventDefault();
226
+ if (event.dataTransfer) {
227
+ event.dataTransfer.dropEffect = 'move';
228
+ }
229
+ this.clearDropMarks();
230
+ const rect = target.getBoundingClientRect();
231
+ const before = event.clientY < rect.top + rect.height / 2;
232
+ target.classList.add(before ? 'slide-row--drop-before' : 'slide-row--drop-after');
233
+ }
234
+
235
+ private onDrop(event: DragEvent): void {
236
+ if (!this.dragId) {
237
+ return;
238
+ }
239
+ const target = this.rowAt(event);
240
+ if (!target) {
241
+ return;
242
+ }
243
+ event.preventDefault();
244
+ const before = target.classList.contains('slide-row--drop-before');
245
+ const from = this.rows.findIndex((row) => row.id === this.dragId);
246
+ const targetIndex = this.rows.findIndex((row) => row.id === target.dataset.id);
247
+ this.clearDropMarks();
248
+ if (from === -1 || targetIndex === -1 || from === targetIndex) {
249
+ return;
250
+ }
251
+ const [row] = this.rows.splice(from, 1);
252
+ let to = before ? targetIndex : targetIndex + 1;
253
+ if (from < to) {
254
+ to -= 1;
255
+ }
256
+ this.rows.splice(to, 0, row!);
257
+ this.render();
258
+ this.announce(`Moved "${this.name(row!)}" to position ${to + 1} of ${this.rows.length}.`);
259
+ }
260
+
261
+ /** The row under a drag event, or the last row when the event is on the list itself. */
262
+ private rowAt(event: DragEvent): HTMLElement | null {
263
+ if (event.target === this.list) {
264
+ return this.list.querySelector<HTMLElement>('.slide-row:last-child');
265
+ }
266
+ const target =
267
+ event.target instanceof Element ? event.target.closest<HTMLElement>('.slide-row') : null;
268
+ return target && this.list.contains(target) ? target : null;
269
+ }
270
+
271
+ private clearDropMarks(): void {
272
+ for (const element of this.list.querySelectorAll('.slide-row--drop-before, .slide-row--drop-after')) {
273
+ element.classList.remove('slide-row--drop-before', 'slide-row--drop-after');
274
+ }
275
+ }
276
+ }