@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,10 +1,17 @@
1
- import { useMemo, useEffect, useState } from 'preact/hooks';
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 class={cls}>
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
  ✕
@@ -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
@@ -203,6 +203,11 @@ tw-storydata {
203
203
  position: relative;
204
204
  }
205
205
 
206
+ /* Focused programmatically when the dialog has no focusable content */
207
+ .dialog-panel:focus {
208
+ outline: none;
209
+ }
210
+
206
211
  .dialog-saves {
207
212
  max-width: 550px;
208
213
  }
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()`. */