@rohal12/spindle 0.59.18 → 0.59.19

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.
@@ -12480,6 +12480,26 @@ function useViewScope() {
12480
12480
  */
12481
12481
  var SvgContext = createContext(false);
12482
12482
  var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
12483
+ /**
12484
+ * Elements whose children must be elements of particular types: markdown
12485
+ * paragraphs between them would pull the children out of the structure (#423).
12486
+ * Text between the children, when it is only whitespace, is dropped.
12487
+ */
12488
+ var STRUCTURAL_ELEMENTS = new Set([
12489
+ "table",
12490
+ "thead",
12491
+ "tbody",
12492
+ "tfoot",
12493
+ "tr",
12494
+ "colgroup",
12495
+ "ul",
12496
+ "ol",
12497
+ "menu",
12498
+ "dl",
12499
+ "select",
12500
+ "optgroup",
12501
+ "datalist"
12502
+ ]);
12483
12503
  /** Elements whose content is literal text, not markdown. */
12484
12504
  var PREFORMATTED_ELEMENTS = new Set([
12485
12505
  "pre",
@@ -12940,7 +12960,7 @@ function HtmlNodeRenderer({ node }) {
12940
12960
  locals,
12941
12961
  inline: isInline
12942
12962
  });
12943
- children = summaryAt === -1 ? body(node.children) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
12963
+ children = STRUCTURAL_ELEMENTS.has(tag) && node.children.every((n) => n.type !== "text" || n.value.trim() === "") ? node.children.filter((n) => n.type !== "text").map((n) => renderSingleNode(n)) : summaryAt === -1 ? body(node.children) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
12944
12964
  body(node.children.slice(0, summaryAt)),
12945
12965
  renderSingleNode(node.children[summaryAt]),
12946
12966
  body(node.children.slice(summaryAt + 1))
@@ -18487,7 +18507,7 @@ function useModalFocus(panelRef, bodySelector, dismissible, onClose) {
18487
18507
  const entry = { panel };
18488
18508
  openModals.push(entry);
18489
18509
  const body = panel.querySelector(bodySelector) ?? panel;
18490
- (panel.querySelector("[autofocus]") ?? focusables(body)[0] ?? panel).focus();
18510
+ (Array.from(panel.querySelectorAll("[autofocus]")).find(isAvailable) ?? focusables(body)[0] ?? panel).focus();
18491
18511
  return () => {
18492
18512
  const i = openModals.indexOf(entry);
18493
18513
  if (i !== -1) openModals.splice(i, 1);
@@ -18498,8 +18518,33 @@ function useModalFocus(panelRef, bodySelector, dismissible, onClose) {
18498
18518
  useLayoutEffect(() => {
18499
18519
  const panel = panelRef.current;
18500
18520
  if (!panel) return;
18521
+ const top = () => openModals[openModals.length - 1]?.panel === panel;
18522
+ /**
18523
+ * Move focus to the next (or previous) modal control from `from`. The
18524
+ * order is computed here, never left to the browser: its positive-tabindex
18525
+ * sequence spans the whole document and would lead out of the modal
18526
+ * (#424). `from` may be an element of an embedded document, whose frame
18527
+ * element then stands in for it.
18528
+ */
18529
+ const moveFocus = (from, backwards) => {
18530
+ const items = focusables(panel);
18531
+ const first = items[0];
18532
+ const last = items[items.length - 1];
18533
+ if (!first || !last) {
18534
+ panel.focus();
18535
+ return;
18536
+ }
18537
+ const at = from ? items.indexOf(from) : -1;
18538
+ let next;
18539
+ if (at !== -1) next = items[at + (backwards ? -1 : 1)];
18540
+ else if (from && from !== panel && panel.contains(from)) {
18541
+ const after = (el) => !!(from.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING);
18542
+ next = backwards ? items.filter((el) => !after(el)).pop() : items.find(after);
18543
+ } else next = backwards ? last : first;
18544
+ (next ?? (backwards ? last : first)).focus();
18545
+ };
18501
18546
  const onKeyDown = (e) => {
18502
- if (openModals[openModals.length - 1]?.panel !== panel) return;
18547
+ if (!top()) return;
18503
18548
  if (e.key === "Escape") {
18504
18549
  if (!dismissible) return;
18505
18550
  e.preventDefault();
@@ -18508,24 +18553,60 @@ function useModalFocus(panelRef, bodySelector, dismissible, onClose) {
18508
18553
  return;
18509
18554
  }
18510
18555
  if (e.key !== "Tab") return;
18511
- const items = focusables(panel);
18512
- const first = items[0];
18513
- const last = items[items.length - 1];
18514
- const active = document.activeElement;
18515
- const inside = active !== panel && panel.contains(active);
18516
- if (!first || !last) {
18517
- e.preventDefault();
18518
- panel.focus();
18519
- } else if (e.shiftKey && (active === first || !inside)) {
18520
- e.preventDefault();
18521
- last.focus();
18522
- } else if (!e.shiftKey && (active === last || !inside)) {
18523
- e.preventDefault();
18524
- first.focus();
18525
- }
18556
+ e.preventDefault();
18557
+ moveFocus(document.activeElement, e.shiftKey);
18558
+ };
18559
+ const controller = new AbortController();
18560
+ const { signal } = controller;
18561
+ const frameDocs = /* @__PURE__ */ new Map();
18562
+ const attachFrame = (frame) => {
18563
+ let doc = null;
18564
+ try {
18565
+ doc = frame.contentDocument;
18566
+ } catch {}
18567
+ if (!doc || frameDocs.get(frame) === doc) return;
18568
+ frameDocs.set(frame, doc);
18569
+ const onFrameKeyDown = (e) => {
18570
+ if (!top()) return;
18571
+ if (e.key !== "Tab") return onKeyDown(e);
18572
+ const inner = Array.from(doc.querySelectorAll(FOCUSABLE)).filter((el) => isAvailable(el) && isTabStop(el));
18573
+ const active = doc.activeElement;
18574
+ const edge = e.shiftKey ? inner[0] : inner[inner.length - 1];
18575
+ if (!edge || active === edge || active === doc.body) {
18576
+ e.preventDefault();
18577
+ moveFocus(frame, e.shiftKey);
18578
+ }
18579
+ };
18580
+ doc.addEventListener("keydown", onFrameKeyDown, { signal });
18581
+ };
18582
+ const attachFrames = () => {
18583
+ panel.querySelectorAll("iframe").forEach(attachFrame);
18584
+ };
18585
+ const onLoad = (e) => {
18586
+ if (e.target instanceof HTMLIFrameElement) attachFrame(e.target);
18587
+ };
18588
+ panel.addEventListener("load", onLoad, {
18589
+ capture: true,
18590
+ signal
18591
+ });
18592
+ const frames = new MutationObserver(attachFrames);
18593
+ frames.observe(panel, {
18594
+ childList: true,
18595
+ subtree: true
18596
+ });
18597
+ attachFrames();
18598
+ const onFocusIn = (e) => {
18599
+ const target = e.target;
18600
+ if (!top() || !(target instanceof Node) || panel.contains(target)) return;
18601
+ if (target === document.body || target === document.documentElement) return;
18602
+ moveFocus(null, false);
18603
+ };
18604
+ document.addEventListener("keydown", onKeyDown, { signal });
18605
+ document.addEventListener("focusin", onFocusIn, { signal });
18606
+ return () => {
18607
+ controller.abort();
18608
+ frames.disconnect();
18526
18609
  };
18527
- document.addEventListener("keydown", onKeyDown);
18528
- return () => document.removeEventListener("keydown", onKeyDown);
18529
18610
  }, [dismissible, onClose]);
18530
18611
  }
18531
18612
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.18",
3
+ "version": "0.59.19",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -165,8 +165,12 @@ export function useModalFocus(
165
165
  openModals.push(entry);
166
166
 
167
167
  const body = panel.querySelector<HTMLElement>(bodySelector) ?? panel;
168
+ // An autofocus element that can't take focus (hidden, disabled, inert)
169
+ // must not shadow the eligible controls (#426).
168
170
  const initial =
169
- panel.querySelector<HTMLElement>('[autofocus]') ??
171
+ Array.from(panel.querySelectorAll<HTMLElement>('[autofocus]')).find(
172
+ isAvailable,
173
+ ) ??
170
174
  focusables(body)[0] ??
171
175
  panel;
172
176
  initial.focus();
@@ -187,8 +191,44 @@ export function useModalFocus(
187
191
  const panel = panelRef.current;
188
192
  if (!panel) return;
189
193
 
194
+ const top = () => openModals[openModals.length - 1]?.panel === panel;
195
+
196
+ /**
197
+ * Move focus to the next (or previous) modal control from `from`. The
198
+ * order is computed here, never left to the browser: its positive-tabindex
199
+ * sequence spans the whole document and would lead out of the modal
200
+ * (#424). `from` may be an element of an embedded document, whose frame
201
+ * element then stands in for it.
202
+ */
203
+ const moveFocus = (from: Element | null, backwards: boolean) => {
204
+ const items = focusables(panel);
205
+ const first = items[0];
206
+ const last = items[items.length - 1];
207
+ if (!first || !last) {
208
+ panel.focus();
209
+ return;
210
+ }
211
+ const at = from ? items.indexOf(from as HTMLElement) : -1;
212
+ let next: HTMLElement | undefined;
213
+ if (at !== -1) {
214
+ next = items[at + (backwards ? -1 : 1)];
215
+ } else if (from && from !== panel && panel.contains(from)) {
216
+ // Inside the panel but not a tab stop: continue from its position
217
+ const after = (el: HTMLElement) =>
218
+ !!(
219
+ from.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING
220
+ );
221
+ next = backwards
222
+ ? items.filter((el) => !after(el)).pop()
223
+ : items.find(after);
224
+ } else {
225
+ next = backwards ? last : first;
226
+ }
227
+ (next ?? (backwards ? last : first)).focus();
228
+ };
229
+
190
230
  const onKeyDown = (e: KeyboardEvent) => {
191
- if (openModals[openModals.length - 1]?.panel !== panel) return;
231
+ if (!top()) return;
192
232
 
193
233
  if (e.key === 'Escape') {
194
234
  if (!dismissible) return;
@@ -199,25 +239,69 @@ export function useModalFocus(
199
239
  }
200
240
 
201
241
  if (e.key !== 'Tab') return;
202
- const items = focusables(panel);
203
- const first = items[0];
204
- const last = items[items.length - 1];
205
- const active = document.activeElement;
206
- // The panel itself (tabindex=-1) counts as outside the tab sequence.
207
- const inside = active !== panel && panel.contains(active);
208
- if (!first || !last) {
209
- e.preventDefault();
210
- panel.focus();
211
- } else if (e.shiftKey && (active === first || !inside)) {
212
- e.preventDefault();
213
- last.focus();
214
- } else if (!e.shiftKey && (active === last || !inside)) {
215
- e.preventDefault();
216
- first.focus();
242
+ e.preventDefault();
243
+ moveFocus(document.activeElement, e.shiftKey);
244
+ };
245
+
246
+ // Key events inside an embedded document never reach this one (#425).
247
+ // Escape closes the dialog from there too; Tab moves on natively within
248
+ // the frame and is taken over at its first and last controls.
249
+ const controller = new AbortController();
250
+ const { signal } = controller;
251
+ const frameDocs = new Map<HTMLIFrameElement, Document>();
252
+ const attachFrame = (frame: HTMLIFrameElement) => {
253
+ let doc: Document | null = null;
254
+ try {
255
+ doc = frame.contentDocument;
256
+ } catch {
257
+ // Not accessible
217
258
  }
259
+ // Already listening to this document
260
+ if (!doc || frameDocs.get(frame) === doc) return;
261
+ frameDocs.set(frame, doc);
262
+ const onFrameKeyDown = (e: KeyboardEvent) => {
263
+ if (!top()) return;
264
+ if (e.key !== 'Tab') return onKeyDown(e);
265
+ const inner = Array.from(
266
+ doc!.querySelectorAll<HTMLElement>(FOCUSABLE),
267
+ ).filter((el) => isAvailable(el) && isTabStop(el));
268
+ const active = doc!.activeElement;
269
+ const edge = e.shiftKey ? inner[0] : inner[inner.length - 1];
270
+ if (!edge || active === edge || active === doc!.body) {
271
+ e.preventDefault();
272
+ moveFocus(frame, e.shiftKey);
273
+ }
274
+ };
275
+ doc.addEventListener('keydown', onFrameKeyDown, { signal });
276
+ };
277
+ const attachFrames = () => {
278
+ panel.querySelectorAll('iframe').forEach(attachFrame);
218
279
  };
280
+ // load doesn't bubble; a frame's document is replaced when it navigates
281
+ const onLoad = (e: Event) => {
282
+ if (e.target instanceof HTMLIFrameElement) attachFrame(e.target);
283
+ };
284
+ panel.addEventListener('load', onLoad, { capture: true, signal });
285
+ const frames = new MutationObserver(attachFrames);
286
+ frames.observe(panel, { childList: true, subtree: true });
287
+ attachFrames();
219
288
 
220
- document.addEventListener('keydown', onKeyDown);
221
- return () => document.removeEventListener('keydown', onKeyDown);
289
+ // Focus that lands outside the dialog anyway, e.g. leaving a frame whose
290
+ // document can't be reached, is brought back in.
291
+ const onFocusIn = (e: FocusEvent) => {
292
+ const target = e.target;
293
+ if (!top() || !(target instanceof Node) || panel.contains(target)) return;
294
+ if (target === document.body || target === document.documentElement) {
295
+ return;
296
+ }
297
+ moveFocus(null, false);
298
+ };
299
+
300
+ document.addEventListener('keydown', onKeyDown, { signal });
301
+ document.addEventListener('focusin', onFocusIn, { signal });
302
+ return () => {
303
+ controller.abort();
304
+ frames.disconnect();
305
+ };
222
306
  }, [dismissible, onClose]);
223
307
  }
@@ -84,6 +84,27 @@ const SvgContext = createContext(false);
84
84
 
85
85
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
86
86
 
87
+ /**
88
+ * Elements whose children must be elements of particular types: markdown
89
+ * paragraphs between them would pull the children out of the structure (#423).
90
+ * Text between the children, when it is only whitespace, is dropped.
91
+ */
92
+ const STRUCTURAL_ELEMENTS = new Set([
93
+ 'table',
94
+ 'thead',
95
+ 'tbody',
96
+ 'tfoot',
97
+ 'tr',
98
+ 'colgroup',
99
+ 'ul',
100
+ 'ol',
101
+ 'menu',
102
+ 'dl',
103
+ 'select',
104
+ 'optgroup',
105
+ 'datalist',
106
+ ]);
107
+
87
108
  /** Elements whose content is literal text, not markdown. */
88
109
  const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
89
110
  export const WidgetChildrenContext = createContext<WidgetChildren | null>(null);
@@ -693,16 +714,22 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
693
714
  tag === 'details' ? leadingSummaryIndex(node.children) : -1;
694
715
  const body = (nodes: ASTNode[]) =>
695
716
  renderNodes(nodes, { nobr, locals, inline: isInline });
696
- children =
697
- summaryAt === -1 ? (
698
- body(node.children)
699
- ) : (
700
- <>
701
- {body(node.children.slice(0, summaryAt))}
702
- {renderSingleNode(node.children[summaryAt]!)}
703
- {body(node.children.slice(summaryAt + 1))}
704
- </>
705
- );
717
+ const structural =
718
+ STRUCTURAL_ELEMENTS.has(tag) &&
719
+ node.children.every((n) => n.type !== 'text' || n.value.trim() === '');
720
+ children = structural ? (
721
+ node.children
722
+ .filter((n) => n.type !== 'text')
723
+ .map((n) => renderSingleNode(n))
724
+ ) : summaryAt === -1 ? (
725
+ body(node.children)
726
+ ) : (
727
+ <>
728
+ {body(node.children.slice(0, summaryAt))}
729
+ {renderSingleNode(node.children[summaryAt]!)}
730
+ {body(node.children.slice(summaryAt + 1))}
731
+ </>
732
+ );
706
733
  if (isInline !== parentInline) {
707
734
  children = (
708
735
  <InlineContext.Provider value={isInline}>