@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/README.md +20 -10
- package/dist/assets/{index-LO1tomgR.css → index-BuPgmhhR.css} +1 -1
- package/dist/assets/{index-bBtGCGL_.js → index-EthD3ZNJ.js} +370 -368
- package/dist/index.html +115 -141
- package/index.html +113 -139
- package/package.json +1 -1
- package/presets/16-themes.json +2 -2
- package/src/app.css +155 -111
- package/src/deck-model.ts +47 -0
- package/src/decks.test.ts +55 -0
- package/src/main.ts +91 -27
- package/src/present.ts +9 -0
- package/src/slide-list.ts +276 -0
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
|
-
/**
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|