@rohal12/spindle 0.59.17 → 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.
@@ -151,7 +151,11 @@ function lexeme(lex, loc, fields) {
151
151
  /** The AST node of a lexeme that stands alone (not a macro or element). */
152
152
  function nodeOf(x) {
153
153
  switch (x.lex) {
154
- case "text": return {
154
+ case "text": return x.comment ? {
155
+ type: "text",
156
+ value: x.value,
157
+ comment: true
158
+ } : {
155
159
  type: "text",
156
160
  value: x.value
157
161
  };
@@ -223,7 +227,7 @@ function tokenOf(x) {
223
227
  }
224
228
  /**
225
229
  * Flatten items (lexemes, nodes, arrays of them, nulls) and join adjacent
226
- * text, except escaped braces, which stay a text item of their own.
230
+ * text, except escaped braces and comments, which stay a text item of their own.
227
231
  */
228
232
  function joinText(items, convert) {
229
233
  const out = [];
@@ -231,7 +235,7 @@ function joinText(items, convert) {
231
235
  const add = (item) => {
232
236
  if (item === null || item === void 0) return;
233
237
  if (Array.isArray(item)) return item.forEach(add);
234
- if (item.lex === "text" && !item.escape && last && last.lex === "text" && !last.escape && last.end === item.start) {
238
+ if (item.lex === "text" && !item.escape && !item.comment && last && last.lex === "text" && !last.escape && !last.comment && last.end === item.start) {
235
239
  last.value += item.value;
236
240
  last.end = item.end;
237
241
  return;
@@ -725,7 +729,9 @@ function peg$parse(input, options) {
725
729
  return lexeme("text", location(), { value });
726
730
  }
727
731
  function peg$f41(value) {
728
- return lexeme("text", location(), { value });
732
+ const node = lexeme("text", location(), { value });
733
+ node.comment = true;
734
+ return node;
729
735
  }
730
736
  function peg$f42(bs) {
731
737
  return bs.length % 2 === 1;
@@ -10586,70 +10592,6 @@ function evaluatePassageName(expr, evaluate, { storyData, currentPassage }) {
10586
10592
  if (storyData && !storyData.passages.has(name)) throw noPassageError(name, currentPassage);
10587
10593
  return name;
10588
10594
  }
10589
- //#endregion
10590
- //#region src/widgets/widget-def.ts
10591
- /**
10592
- * Reading widget definitions without rendering anything: the `{widget}`
10593
- * macro, startup and markup validation (also in tooling) share these.
10594
- */
10595
- /** A {widget} definition's name, then its `@` parameters. */
10596
- var WIDGET_PARAMETERS = [{
10597
- name: "name",
10598
- type: "text",
10599
- holds: "text",
10600
- required: true
10601
- }, {
10602
- name: "parameters",
10603
- type: "text",
10604
- holds: "text"
10605
- }];
10606
- /**
10607
- * The widget a {widget} definition's arguments declare: its name and its
10608
- * parameters, the words after it that start with `@` (docs/widgets.md).
10609
- * Other words, such as `$name`, are not parameters.
10610
- */
10611
- function widgetDef({ name = "", parameters = "" }) {
10612
- return {
10613
- name,
10614
- params: parameters.split(/\s+/).filter((word) => word.startsWith("@"))
10615
- };
10616
- }
10617
- /** Read the arguments of a {widget} definition (see widgetDef). */
10618
- function parseWidgetDef(rawArgs) {
10619
- return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
10620
- }
10621
- /**
10622
- * The names of the block widgets (those whose body renders `{@children}`)
10623
- * that StoryInit and the passages tagged `widget` define. They must be
10624
- * known as block macros before any passage is parsed, so that passages
10625
- * invoking them nest their content whatever the passage order.
10626
- *
10627
- * The passages are read as flat tokens, which need no block macros to be
10628
- * known, so a `{@children}` in an HTML comment or in a `{do}` body is text
10629
- * here as it is to the parser, and does not make a block widget (#387).
10630
- */
10631
- function blockWidgetNames(passages) {
10632
- const names = [];
10633
- for (const passage of passages) {
10634
- if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
10635
- const open = [];
10636
- for (const token of tokenizeMarkupTolerant(passage.content).tokens) if (token.type === "macro" && token.name.toLowerCase() === "widget") {
10637
- if (!token.isClose) {
10638
- open.push({
10639
- rawArgs: token.rawArgs,
10640
- isBlock: false
10641
- });
10642
- continue;
10643
- }
10644
- const def = open.pop();
10645
- if (!def?.isBlock) continue;
10646
- try {
10647
- names.push(parseWidgetDef(def.rawArgs).name);
10648
- } catch {}
10649
- } else if (token.type === "variable" && token.scope === "local" && token.name === "children" && open.length > 0) open[open.length - 1].isBlock = true;
10650
- }
10651
- return names;
10652
- }
10653
10595
  /** The parameters of the built-in sub-macros that take arguments. */
10654
10596
  var SUB_MACRO_PARAMETERS = new Map([["option", [{
10655
10597
  name: "value",
@@ -10983,6 +10925,214 @@ function collectPassageReferences(src, tokens, parametersOf) {
10983
10925
  });
10984
10926
  return refs;
10985
10927
  }
10928
+ new NameMap();
10929
+ new NameMap();
10930
+ new NameSet();
10931
+ /**
10932
+ * How a macro argument is read (see components/macros/macro-args.ts). Every
10933
+ * declared parameter names one: there is no default.
10934
+ * Quoted strings accept `\"`, `\'` and `\\` escapes.
10935
+ * - `expression`: code, as written.
10936
+ * - `statements`: code run as statements (`{set}`), as written.
10937
+ * - `passage`: a passage name: a quoted string or an expression, as written.
10938
+ * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
10939
+ * - `string`: one quoted string; anything else leaves the argument unset.
10940
+ * - `text`: one quoted string, or text with any loose quotes stripped.
10941
+ * - `names`: a comma-separated list of names (`@item, @i`).
10942
+ * - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
10943
+ * - `number`: a number.
10944
+ * - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
10945
+ * - `separator`: a word (`of`) or `=` separating the parameters before it
10946
+ * from those after it; a boolean.
10947
+ * - `options`: keywords, the names of its `parameters`, each followed by a
10948
+ * quoted string or a number unless it is a flag.
10949
+ */
10950
+ var PARAMETER_TYPES = [
10951
+ "expression",
10952
+ "statements",
10953
+ "passage",
10954
+ "variable",
10955
+ "string",
10956
+ "text",
10957
+ "names",
10958
+ "delay",
10959
+ "number",
10960
+ "flag",
10961
+ "separator",
10962
+ "options"
10963
+ ];
10964
+ /**
10965
+ * What the value of a `string` or `text` argument holds, for the check at
10966
+ * story start and for tooling (see code-check.ts):
10967
+ * - `markup`: markup the macro renders (`{button}`'s label): its markup
10968
+ * is checked.
10969
+ * - `text`: plain text the macro uses as written (`{checkbox}`'s label).
10970
+ * - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
10971
+ * - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
10972
+ * is checked as code, and its variable references against the schema.
10973
+ *
10974
+ * Without it, the argument of a macro with `interpolate` holds `markup`,
10975
+ * any other `text`.
10976
+ */
10977
+ var STRING_HOLDS = [
10978
+ "markup",
10979
+ "text",
10980
+ "passage",
10981
+ "expression",
10982
+ "statements"
10983
+ ];
10984
+ /**
10985
+ * Throw if a parameter `macro` declares (or an option of one) has no type,
10986
+ * or one that isn't a ParameterType: arguments are read by their type, and
10987
+ * there is no default. Throw too if it declares what it `holds` wrongly.
10988
+ */
10989
+ function checkParameterTypes(macro, parameters) {
10990
+ const holdsFix = `Give a \`string\` or \`text\` parameter one of ${STRING_HOLDS.join(", ")} to hold (see docs/custom-macros.md#what-a-string-holds).`;
10991
+ for (const param of parameters) {
10992
+ const type = param.type;
10993
+ if (!PARAMETER_TYPES.includes(type)) throw parameterError(macro, param, type === void 0 ? "has no type" : `has the unknown type ${JSON.stringify(type)}`, `Give it one of the types ${PARAMETER_TYPES.join(", ")} (see docs/custom-macros.md#parameter-types), or declare no parameters and read props.rawArgs.`);
10994
+ const holds = param.holds;
10995
+ if (holds !== void 0 && type !== "string" && type !== "text") throw parameterError(macro, param, `is of the type ${type}, which holds what it is`, holdsFix);
10996
+ if (holds !== void 0 && !STRING_HOLDS.includes(holds)) throw parameterError(macro, param, `holds the unknown ${JSON.stringify(holds)}`, holdsFix);
10997
+ if (param.parameters) checkParameterTypes(macro, param.parameters);
10998
+ }
10999
+ }
11000
+ /** The error for the parameter `param` of `macro`: what is wrong, and the fix. */
11001
+ function parameterError(macro, param, problem, fix) {
11002
+ return /* @__PURE__ */ new Error(`spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ${fix}`);
11003
+ }
11004
+ var metadataRegistry = new NameMap();
11005
+ function getMacroRegistry() {
11006
+ return Array.from(metadataRegistry.values());
11007
+ }
11008
+ //#endregion
11009
+ //#region src/widgets/ast-scanner.ts
11010
+ /** The declared parameters of the macros registered so far. */
11011
+ var registeredParameters = (name) => parameterLookup(getMacroRegistry())(name);
11012
+ /**
11013
+ * Whether the text of an attribute value or macro label renders a
11014
+ * `{@children}`: it is markup, parsed as the renderer parses it.
11015
+ */
11016
+ function textContainsChildren(text, parametersOf) {
11017
+ if (!text.includes("{")) return false;
11018
+ let nodes;
11019
+ try {
11020
+ nodes = parseMarkup(text, { text: true });
11021
+ } catch {
11022
+ return false;
11023
+ }
11024
+ return astContainsChildren(nodes, parametersOf);
11025
+ }
11026
+ /**
11027
+ * Whether a tag renders a `{@children}` in an attribute value or a macro
11028
+ * argument that holds markup (`<span title="{@children}">`,
11029
+ * `{button "{@children}"}`). Literal arguments and code are not markup.
11030
+ */
11031
+ function tokenTextContainsChildren(token, parametersOf = registeredParameters) {
11032
+ for (const piece of codeAndText("", [token], parametersOf)) if (piece.kind === "text" && textContainsChildren(piece.text, parametersOf)) return true;
11033
+ return false;
11034
+ }
11035
+ /**
11036
+ * Recursively scan an AST node array for a VariableNode
11037
+ * with scope 'local' and name 'children' (@children), in the content and in
11038
+ * the attributes and labels that render markup.
11039
+ */
11040
+ function astContainsChildren(nodes, parametersOf = registeredParameters) {
11041
+ for (const node of nodes) {
11042
+ if (node.type === "variable" && node.scope === "local" && node.name === "children") return true;
11043
+ if (node.type === "html") {
11044
+ const span = {
11045
+ start: 0,
11046
+ end: 0
11047
+ };
11048
+ if (tokenTextContainsChildren({
11049
+ isClose: false,
11050
+ isSelfClose: false,
11051
+ ...node,
11052
+ ...span
11053
+ }, parametersOf) || astContainsChildren(node.children, parametersOf)) return true;
11054
+ }
11055
+ if (node.type === "macro") {
11056
+ const span = {
11057
+ start: 0,
11058
+ end: 0
11059
+ };
11060
+ if (tokenTextContainsChildren({
11061
+ isClose: false,
11062
+ ...node,
11063
+ ...span
11064
+ }, parametersOf) || astContainsChildren(node.children, parametersOf)) return true;
11065
+ if (node.branches) {
11066
+ for (const branch of node.branches) if (astContainsChildren(branch.children, parametersOf)) return true;
11067
+ }
11068
+ }
11069
+ }
11070
+ return false;
11071
+ }
11072
+ //#endregion
11073
+ //#region src/widgets/widget-def.ts
11074
+ /**
11075
+ * Reading widget definitions without rendering anything: the `{widget}`
11076
+ * macro, startup and markup validation (also in tooling) share these.
11077
+ */
11078
+ /** A {widget} definition's name, then its `@` parameters. */
11079
+ var WIDGET_PARAMETERS = [{
11080
+ name: "name",
11081
+ type: "text",
11082
+ holds: "text",
11083
+ required: true
11084
+ }, {
11085
+ name: "parameters",
11086
+ type: "text",
11087
+ holds: "text"
11088
+ }];
11089
+ /**
11090
+ * The widget a {widget} definition's arguments declare: its name and its
11091
+ * parameters, the words after it that start with `@` (docs/widgets.md).
11092
+ * Other words, such as `$name`, are not parameters.
11093
+ */
11094
+ function widgetDef({ name = "", parameters = "" }) {
11095
+ return {
11096
+ name,
11097
+ params: parameters.split(/\s+/).filter((word) => word.startsWith("@"))
11098
+ };
11099
+ }
11100
+ /** Read the arguments of a {widget} definition (see widgetDef). */
11101
+ function parseWidgetDef(rawArgs) {
11102
+ return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
11103
+ }
11104
+ /**
11105
+ * The names of the block widgets (those whose body renders `{@children}`)
11106
+ * that StoryInit and the passages tagged `widget` define. They must be
11107
+ * known as block macros before any passage is parsed, so that passages
11108
+ * invoking them nest their content whatever the passage order.
11109
+ *
11110
+ * The passages are read as flat tokens, which need no block macros to be
11111
+ * known, so a `{@children}` in an HTML comment or in a `{do}` body is text
11112
+ * here as it is to the parser, and does not make a block widget (#387).
11113
+ */
11114
+ function blockWidgetNames(passages, parametersOf = registeredParameters) {
11115
+ const names = [];
11116
+ for (const passage of passages) {
11117
+ if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
11118
+ const open = [];
11119
+ for (const token of tokenizeMarkupTolerant(passage.content).tokens) if (token.type === "macro" && token.name.toLowerCase() === "widget") {
11120
+ if (!token.isClose) {
11121
+ open.push({
11122
+ rawArgs: token.rawArgs,
11123
+ isBlock: false
11124
+ });
11125
+ continue;
11126
+ }
11127
+ const def = open.pop();
11128
+ if (!def?.isBlock) continue;
11129
+ try {
11130
+ names.push(parseWidgetDef(def.rawArgs).name);
11131
+ } catch {}
11132
+ } else if (open.length > 0 && (token.type === "variable" && token.scope === "local" && token.name === "children" || tokenTextContainsChildren(token, parametersOf))) open[open.length - 1].isBlock = true;
11133
+ }
11134
+ return names;
11135
+ }
10986
11136
  //#endregion
10987
11137
  //#region src/markup/validate.ts
10988
11138
  /**
@@ -11157,83 +11307,6 @@ function validateMarkup(passages, options) {
11157
11307
  for (const [passage, tokens] of parsed) withParseCache(() => checkTokens(passage, passage.content, tokens, 0, ""));
11158
11308
  return diagnostics;
11159
11309
  }
11160
- new NameMap();
11161
- new NameMap();
11162
- new NameSet();
11163
- /**
11164
- * How a macro argument is read (see components/macros/macro-args.ts). Every
11165
- * declared parameter names one: there is no default.
11166
- * Quoted strings accept `\"`, `\'` and `\\` escapes.
11167
- * - `expression`: code, as written.
11168
- * - `statements`: code run as statements (`{set}`), as written.
11169
- * - `passage`: a passage name: a quoted string or an expression, as written.
11170
- * - `variable`: a variable reference such as `$name` or `"$name"`, as written.
11171
- * - `string`: one quoted string; anything else leaves the argument unset.
11172
- * - `text`: one quoted string, or text with any loose quotes stripped.
11173
- * - `names`: a comma-separated list of names (`@item, @i`).
11174
- * - `delay`: a duration (`2s`, `500ms`, `300`) in milliseconds.
11175
- * - `number`: a number.
11176
- * - `flag`: a keyword, the parameter's name, at the start or end; a boolean.
11177
- * - `separator`: a word (`of`) or `=` separating the parameters before it
11178
- * from those after it; a boolean.
11179
- * - `options`: keywords, the names of its `parameters`, each followed by a
11180
- * quoted string or a number unless it is a flag.
11181
- */
11182
- var PARAMETER_TYPES = [
11183
- "expression",
11184
- "statements",
11185
- "passage",
11186
- "variable",
11187
- "string",
11188
- "text",
11189
- "names",
11190
- "delay",
11191
- "number",
11192
- "flag",
11193
- "separator",
11194
- "options"
11195
- ];
11196
- /**
11197
- * What the value of a `string` or `text` argument holds, for the check at
11198
- * story start and for tooling (see code-check.ts):
11199
- * - `markup`: markup the macro renders (`{button}`'s label): its markup
11200
- * is checked.
11201
- * - `text`: plain text the macro uses as written (`{checkbox}`'s label).
11202
- * - `passage`: a passage name (`{watch}`'s `goto`): the passage must exist.
11203
- * - `expression`, `statements`: code (`{watch}`'s condition and `run`): it
11204
- * is checked as code, and its variable references against the schema.
11205
- *
11206
- * Without it, the argument of a macro with `interpolate` holds `markup`,
11207
- * any other `text`.
11208
- */
11209
- var STRING_HOLDS = [
11210
- "markup",
11211
- "text",
11212
- "passage",
11213
- "expression",
11214
- "statements"
11215
- ];
11216
- /**
11217
- * Throw if a parameter `macro` declares (or an option of one) has no type,
11218
- * or one that isn't a ParameterType: arguments are read by their type, and
11219
- * there is no default. Throw too if it declares what it `holds` wrongly.
11220
- */
11221
- function checkParameterTypes(macro, parameters) {
11222
- const holdsFix = `Give a \`string\` or \`text\` parameter one of ${STRING_HOLDS.join(", ")} to hold (see docs/custom-macros.md#what-a-string-holds).`;
11223
- for (const param of parameters) {
11224
- const type = param.type;
11225
- if (!PARAMETER_TYPES.includes(type)) throw parameterError(macro, param, type === void 0 ? "has no type" : `has the unknown type ${JSON.stringify(type)}`, `Give it one of the types ${PARAMETER_TYPES.join(", ")} (see docs/custom-macros.md#parameter-types), or declare no parameters and read props.rawArgs.`);
11226
- const holds = param.holds;
11227
- if (holds !== void 0 && type !== "string" && type !== "text") throw parameterError(macro, param, `is of the type ${type}, which holds what it is`, holdsFix);
11228
- if (holds !== void 0 && !STRING_HOLDS.includes(holds)) throw parameterError(macro, param, `holds the unknown ${JSON.stringify(holds)}`, holdsFix);
11229
- if (param.parameters) checkParameterTypes(macro, param.parameters);
11230
- }
11231
- }
11232
- /** The error for the parameter `param` of `macro`: what is wrong, and the fix. */
11233
- function parameterError(macro, param, problem, fix) {
11234
- return /* @__PURE__ */ new Error(`spindle: The parameter "${param.name}" of the macro {${macro}} ${problem}. ${fix}`);
11235
- }
11236
- new NameMap();
11237
11310
  //#endregion
11238
11311
  //#region src/utils/namespace.ts
11239
11312
  /** The variable name no namespace can hold. */
@@ -11430,7 +11503,8 @@ function validateStoryMarkup(passages, macros, options = {}) {
11430
11503
  const list = [...passages];
11431
11504
  const all = [...macros];
11432
11505
  const known = /* @__PURE__ */ new Set();
11433
- const blocks = new Set(blockWidgetNames(list).map((n) => n.toLowerCase()));
11506
+ const parametersOf = parameterLookup(all);
11507
+ const blocks = new Set(blockWidgetNames(list, parametersOf).map((n) => n.toLowerCase()));
11434
11508
  for (const macro of all) {
11435
11509
  const name = macro.name.toLowerCase();
11436
11510
  known.add(name);
@@ -11440,7 +11514,7 @@ function validateStoryMarkup(passages, macros, options = {}) {
11440
11514
  return validateMarkup(list, {
11441
11515
  isKnownMacro: (name) => known.has(name),
11442
11516
  macroNames: known,
11443
- parametersOf: parameterLookup(all),
11517
+ parametersOf,
11444
11518
  isBlockMacro: (name) => blocks.has(name.toLowerCase()) || isBlockMacro(name),
11445
11519
  checkPassageNames: options.checkPassageNames
11446
11520
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.17",
3
+ "version": "0.59.19",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,7 +1,12 @@
1
- import { useMemo } from 'preact/hooks';
1
+ import { useLayoutEffect, useMemo } from 'preact/hooks';
2
2
  import { useStoryFields } from '../hooks/use-story-fields';
3
3
  import { parseMarkup } from '../markup/parse';
4
- import { renderInlineNodes, NobrContext } from '../markup/render';
4
+ import {
5
+ renderInlineNodes,
6
+ InterfaceContext,
7
+ NobrContext,
8
+ } from '../markup/render';
9
+ import { declareInterfaceMounted } from '../triggers';
5
10
  import { errorMessage } from '../utils/error-message';
6
11
 
7
12
  const DEFAULT_MARKUP =
@@ -26,9 +31,15 @@ export function StoryInterface() {
26
31
  }
27
32
  }, [markup]);
28
33
 
29
- return nobr ? (
30
- <NobrContext.Provider value={true}>{rendered}</NobrContext.Provider>
31
- ) : (
32
- rendered
34
+ useLayoutEffect(declareInterfaceMounted, []);
35
+
36
+ return (
37
+ <InterfaceContext.Provider value={true}>
38
+ {nobr ? (
39
+ <NobrContext.Provider value={true}>{rendered}</NobrContext.Provider>
40
+ ) : (
41
+ rendered
42
+ )}
43
+ </InterfaceContext.Provider>
33
44
  );
34
45
  }
@@ -1,8 +1,10 @@
1
1
  import { defineMacro } from '../../define-macro';
2
2
  import { FrozenStateContext } from '../../hooks/use-story-fields';
3
3
  import { useStoryStore } from '../../store';
4
+ import { InterfaceContext } from '../../markup/render';
4
5
  import {
5
6
  addMacroTrigger,
7
+ declareInterfaceWatch,
6
8
  removeTrigger,
7
9
  subscribeTriggerResets,
8
10
  triggerResets,
@@ -50,6 +52,15 @@ defineMacro({
50
52
  () => subscribeTriggerResets(() => rerender((n) => n + 1)),
51
53
  [],
52
54
  );
55
+ // The interface's watchers are re-registered by a load of a save made
56
+ // before the interface mounted (#419)
57
+ const inInterface = hooks.useContext(InterfaceContext);
58
+ const declaredKey = JSON.stringify([condition, options]);
59
+ hooks.useLayoutEffect(
60
+ () =>
61
+ inInterface ? declareInterfaceWatch(condition, options) : undefined,
62
+ [inInterface, declaredKey],
63
+ );
53
64
  const registered = hooks.useRef<number | null>(null);
54
65
  if (
55
66
  registered.current === null ||
@@ -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
  }
package/src/markup/ast.ts CHANGED
@@ -8,6 +8,8 @@ import { NameSet } from '../utils/macro-names';
8
8
  export interface TextNode {
9
9
  type: 'text';
10
10
  value: string;
11
+ /** A closed HTML comment: markdown drops it, and so does raw rendering. */
12
+ comment?: true;
11
13
  }
12
14
 
13
15
  export interface VariableNode extends Selectors {