@rohal12/spindle 0.47.0 → 0.49.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/dist/pkg/format.js +1 -1
- package/package.json +1 -1
- package/src/components/Passage.tsx +19 -1
- package/src/components/PassageDialog.tsx +24 -2
- package/src/event-emitter.ts +24 -0
- package/src/hooks/use-modal-focus.ts +110 -0
- package/src/styles.css +5 -0
- package/types/index.d.ts +12 -0
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useMemo,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'preact/hooks';
|
|
2
8
|
import { tokenize } from '../markup/tokenizer';
|
|
3
9
|
import { buildAST } from '../markup/ast';
|
|
4
10
|
import { renderNodes, NobrContext } from '../markup/render';
|
|
5
11
|
import { useStoryStore } from '../store';
|
|
6
12
|
import type { Passage as PassageData } from '../parser';
|
|
7
13
|
import { sourceLocationOf } from '../utils/source-location';
|
|
14
|
+
import { emitFromRender } from '../event-emitter';
|
|
8
15
|
|
|
9
16
|
export function renderPassageContent(passage: PassageData) {
|
|
10
17
|
const tokens = tokenize(passage.content);
|
|
@@ -90,10 +97,21 @@ export function Passage({ passage, dataTransition }: PassageProps) {
|
|
|
90
97
|
}
|
|
91
98
|
}, [doneReady, donePassage?.content]);
|
|
92
99
|
|
|
100
|
+
// Signal that this passage's DOM is committed (after descendants' layout
|
|
101
|
+
// effects, before paint). Runs once per mount: PassageDisplay keys the
|
|
102
|
+
// Passage so every navigation that shows a passage mounts a new one.
|
|
103
|
+
const elRef = useRef<HTMLDivElement>(null);
|
|
104
|
+
useLayoutEffect(() => {
|
|
105
|
+
if (elRef.current) {
|
|
106
|
+
emitFromRender('passagerender', passage.name, elRef.current);
|
|
107
|
+
}
|
|
108
|
+
}, [passage.name]);
|
|
109
|
+
|
|
93
110
|
const nobr = passage.tags.includes('nobr');
|
|
94
111
|
|
|
95
112
|
const inner = (
|
|
96
113
|
<div
|
|
114
|
+
ref={elRef}
|
|
97
115
|
class="passage"
|
|
98
116
|
data-passage={passage.name}
|
|
99
117
|
data-tags={passage.tags.join(' ')}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createContext } from 'preact';
|
|
2
|
-
import { useCallback, useMemo, useRef } from 'preact/hooks';
|
|
2
|
+
import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
|
|
3
3
|
import { tokenize } from '../markup/tokenizer';
|
|
4
4
|
import { buildAST } from '../markup/ast';
|
|
5
5
|
import { renderNodes } from '../markup/render';
|
|
6
6
|
import { useStoryStore } from '../store';
|
|
7
|
+
import { emitFromRender } from '../event-emitter';
|
|
8
|
+
import { useModalFocus } from '../hooks/use-modal-focus';
|
|
7
9
|
|
|
8
10
|
export const DialogCloseContext = createContext<(() => void) | null>(null);
|
|
9
11
|
|
|
@@ -59,6 +61,19 @@ export function PassageDialog({
|
|
|
59
61
|
}
|
|
60
62
|
}, [markup]);
|
|
61
63
|
|
|
64
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
|
|
66
|
+
// Focus into the dialog, trap Tab, Escape to close, restore focus on close.
|
|
67
|
+
// Declared before the dialogrender effect so handlers can move focus.
|
|
68
|
+
useModalFocus(panelRef, '.dialog-body', dismissible, stableOnClose);
|
|
69
|
+
|
|
70
|
+
// Signal that the dialog's DOM is committed (once per open).
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
if (panelRef.current) {
|
|
73
|
+
emitFromRender('dialogrender', passageName ?? '', panelRef.current);
|
|
74
|
+
}
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
62
77
|
const handleBackdrop = (e: MouseEvent) => {
|
|
63
78
|
if (!dismissible) return;
|
|
64
79
|
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
|
|
@@ -74,10 +89,17 @@ export function PassageDialog({
|
|
|
74
89
|
class="dialog-overlay"
|
|
75
90
|
onClick={handleBackdrop}
|
|
76
91
|
>
|
|
77
|
-
<div
|
|
92
|
+
<div
|
|
93
|
+
ref={panelRef}
|
|
94
|
+
class={cls}
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-modal="true"
|
|
97
|
+
tabIndex={-1}
|
|
98
|
+
>
|
|
78
99
|
{showCloseButton && (
|
|
79
100
|
<button
|
|
80
101
|
class="dialog-close"
|
|
102
|
+
aria-label="Close"
|
|
81
103
|
onClick={stableOnClose}
|
|
82
104
|
>
|
|
83
105
|
✕
|
package/src/event-emitter.ts
CHANGED
|
@@ -14,6 +14,8 @@ type EventMap = {
|
|
|
14
14
|
afterload: (slot: string | undefined) => void;
|
|
15
15
|
beforenavigate: (passageName: string) => void;
|
|
16
16
|
afternavigate: (to: string, from: string) => void;
|
|
17
|
+
passagerender: (passage: string, element: HTMLElement) => void;
|
|
18
|
+
dialogrender: (passage: string, element: HTMLElement) => void;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export type StoryEvent = keyof EventMap;
|
|
@@ -30,6 +32,8 @@ const VALID_EVENTS = new Set<string>([
|
|
|
30
32
|
'afterload',
|
|
31
33
|
'beforenavigate',
|
|
32
34
|
'afternavigate',
|
|
35
|
+
'passagerender',
|
|
36
|
+
'dialogrender',
|
|
33
37
|
]);
|
|
34
38
|
|
|
35
39
|
// Each event key maps to a Set of callbacks.
|
|
@@ -65,6 +69,26 @@ export function emit<E extends StoryEvent>(
|
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Emit an event from inside a render commit (layout effect). A throwing
|
|
74
|
+
* handler is logged instead of propagating into Preact and breaking the
|
|
75
|
+
* render; the remaining handlers still run.
|
|
76
|
+
*/
|
|
77
|
+
export function emitFromRender<E extends StoryEvent>(
|
|
78
|
+
event: E,
|
|
79
|
+
...args: Parameters<EventMap[E]>
|
|
80
|
+
): void {
|
|
81
|
+
const set = listeners.get(event);
|
|
82
|
+
if (!set) return;
|
|
83
|
+
for (const cb of [...set]) {
|
|
84
|
+
try {
|
|
85
|
+
(cb as Function)(...args);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(`spindle: Error in ${event} handler:`, err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
/** Test-only: clear all listeners. */
|
|
69
93
|
export function resetEmitter(): void {
|
|
70
94
|
listeners = new Map();
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'preact/hooks';
|
|
2
|
+
import type { RefObject } from 'preact';
|
|
3
|
+
|
|
4
|
+
const FOCUSABLE = [
|
|
5
|
+
'a[href]',
|
|
6
|
+
'area[href]',
|
|
7
|
+
'button:not([disabled])',
|
|
8
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
9
|
+
'select:not([disabled])',
|
|
10
|
+
'textarea:not([disabled])',
|
|
11
|
+
'iframe',
|
|
12
|
+
'[contenteditable="true"]',
|
|
13
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
14
|
+
].join(',');
|
|
15
|
+
|
|
16
|
+
function focusables(root: HTMLElement): HTMLElement[] {
|
|
17
|
+
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
|
18
|
+
(el) => !el.hidden && !el.closest('[hidden], [inert]'),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ModalEntry {
|
|
23
|
+
panel: HTMLElement;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Open modal dialogs, innermost last. Only the topmost handles keys. */
|
|
27
|
+
const openModals: ModalEntry[] = [];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Modal focus management for a dialog panel:
|
|
31
|
+
* - moves focus into the panel on open (an `[autofocus]` element, else the
|
|
32
|
+
* first focusable element of the body, else the panel itself);
|
|
33
|
+
* - keeps Tab / Shift+Tab inside the topmost dialog;
|
|
34
|
+
* - closes the topmost dialog on Escape when it is dismissible;
|
|
35
|
+
* - returns focus to the previously focused element on close.
|
|
36
|
+
*/
|
|
37
|
+
export function useModalFocus(
|
|
38
|
+
panelRef: RefObject<HTMLElement>,
|
|
39
|
+
bodySelector: string,
|
|
40
|
+
dismissible: boolean,
|
|
41
|
+
onClose: () => void,
|
|
42
|
+
): void {
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
const panel = panelRef.current;
|
|
45
|
+
if (!panel) return;
|
|
46
|
+
|
|
47
|
+
const opener =
|
|
48
|
+
document.activeElement instanceof HTMLElement
|
|
49
|
+
? document.activeElement
|
|
50
|
+
: null;
|
|
51
|
+
const entry: ModalEntry = { panel };
|
|
52
|
+
openModals.push(entry);
|
|
53
|
+
|
|
54
|
+
const body = panel.querySelector<HTMLElement>(bodySelector) ?? panel;
|
|
55
|
+
const initial =
|
|
56
|
+
panel.querySelector<HTMLElement>('[autofocus]') ??
|
|
57
|
+
focusables(body)[0] ??
|
|
58
|
+
panel;
|
|
59
|
+
initial.focus();
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
const i = openModals.indexOf(entry);
|
|
63
|
+
if (i !== -1) openModals.splice(i, 1);
|
|
64
|
+
// Restore focus only if it was inside this dialog (or lost to <body>);
|
|
65
|
+
// don't steal it from somewhere author code moved it on purpose.
|
|
66
|
+
const active = document.activeElement;
|
|
67
|
+
const focusWasHere =
|
|
68
|
+
!active || active === document.body || panel.contains(active);
|
|
69
|
+
if (focusWasHere && opener?.isConnected) opener.focus();
|
|
70
|
+
};
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
useLayoutEffect(() => {
|
|
74
|
+
const panel = panelRef.current;
|
|
75
|
+
if (!panel) return;
|
|
76
|
+
|
|
77
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
78
|
+
if (openModals[openModals.length - 1]?.panel !== panel) return;
|
|
79
|
+
|
|
80
|
+
if (e.key === 'Escape') {
|
|
81
|
+
if (!dismissible) return;
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
e.stopPropagation();
|
|
84
|
+
onClose();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (e.key !== 'Tab') return;
|
|
89
|
+
const items = focusables(panel);
|
|
90
|
+
const first = items[0];
|
|
91
|
+
const last = items[items.length - 1];
|
|
92
|
+
const active = document.activeElement;
|
|
93
|
+
// The panel itself (tabindex=-1) counts as outside the tab sequence.
|
|
94
|
+
const inside = active !== panel && panel.contains(active);
|
|
95
|
+
if (!first || !last) {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
panel.focus();
|
|
98
|
+
} else if (e.shiftKey && (active === first || !inside)) {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
last.focus();
|
|
101
|
+
} else if (!e.shiftKey && (active === last || !inside)) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
first.focus();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
document.addEventListener('keydown', onKeyDown);
|
|
108
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
109
|
+
}, [dismissible, onClose]);
|
|
110
|
+
}
|
package/src/styles.css
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -138,6 +138,18 @@ export interface StoryEventMap {
|
|
|
138
138
|
afterload: (slot: string | undefined) => void;
|
|
139
139
|
beforenavigate: (passageName: string) => void;
|
|
140
140
|
afternavigate: (to: string, from: string) => void;
|
|
141
|
+
/**
|
|
142
|
+
* A passage's `.passage` element was committed to the DOM (first render,
|
|
143
|
+
* navigation, back/forward, restart, load). Fires after the transition
|
|
144
|
+
* mounts the new passage, before paint.
|
|
145
|
+
*/
|
|
146
|
+
passagerender: (passage: string, element: HTMLElement) => void;
|
|
147
|
+
/**
|
|
148
|
+
* A dialog was opened and its `.dialog-panel` element committed to the DOM.
|
|
149
|
+
* `passage` is the dialog's passage name (empty for built-in dialogs
|
|
150
|
+
* without one).
|
|
151
|
+
*/
|
|
152
|
+
dialogrender: (passage: string, element: HTMLElement) => void;
|
|
141
153
|
}
|
|
142
154
|
|
|
143
155
|
/** Event name that can be passed to `Story.on()`. */
|