@rohal12/spindle 0.59.13 → 0.59.15

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.59.13",
3
+ "version": "0.59.15",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -2,13 +2,48 @@
2
2
 
3
3
  import { parse, stringify } from 'devalue';
4
4
  import { hasOwn } from './utils/namespace';
5
+ import { extraKeys, mapEntries, setMembers } from './utils/value-kinds';
5
6
 
6
7
  type Constructor = new (...args: any[]) => any;
7
8
 
8
9
  const registry = new Map<string, Constructor>();
9
10
  const ctorToName = new Map<Constructor, string>();
10
11
 
12
+ /**
13
+ * Built-ins whose instances hold their value in internal slots that a save
14
+ * cannot rebuild: a registered class cannot extend them. (Array, Map, Set,
15
+ * Date, RegExp and Error can be extended, see classData.)
16
+ */
17
+ const UNSUPPORTED_BASES: readonly Constructor[] = [
18
+ ArrayBuffer,
19
+ DataView,
20
+ Object.getPrototypeOf(Int8Array) as Constructor, // every typed array
21
+ URL,
22
+ URLSearchParams,
23
+ Number,
24
+ String,
25
+ Boolean,
26
+ Promise,
27
+ WeakMap,
28
+ WeakSet,
29
+ Function,
30
+ ] as Constructor[];
31
+
32
+ /**
33
+ * Register `ctor` as `name`, so that its instances keep their class through
34
+ * copies, history and saves. Throws for a class extending a built-in whose
35
+ * instances cannot be saved (see UNSUPPORTED_BASES).
36
+ */
11
37
  export function registerClass(name: string, ctor: Constructor): void {
38
+ const base =
39
+ typeof ctor === 'function'
40
+ ? UNSUPPORTED_BASES.find((b) => ctor.prototype instanceof b)
41
+ : undefined;
42
+ if (base) {
43
+ throw new TypeError(
44
+ `spindle: Cannot register class "${name}": it extends ${base.name}, whose instances cannot be saved (a registered class may extend Array, Map, Set, Date, RegExp or Error)`,
45
+ );
46
+ }
12
47
  registry.set(name, ctor);
13
48
  ctorToName.set(ctor, name);
14
49
  }
@@ -46,7 +81,11 @@ export function clearRegistry(): void {
46
81
  // values. Spindle adds, with reducers and revivers:
47
82
  //
48
83
  // - `c:<name>`: an instance of the class registered as <name>, with its own
49
- // enumerable keys (and, for Error subclasses, its message and cause);
84
+ // enumerable keys (and, for Error subclasses, its message and cause); for
85
+ // a subclass of Array, Map, Set, Date or RegExp, `[kind, contents, keys]`:
86
+ // the built-in it extends, what that holds (elements, Map entries as
87
+ // pairs, the time, or the source, flags and lastIndex) and its other own
88
+ // enumerable keys;
50
89
  // - `E:<name>`: a built-in error (Error, TypeError, ..., AggregateError),
51
90
  // with its message, cause, errors and own enumerable keys (not its stack);
52
91
  // - `S`: a symbol from the global registry (Symbol.for);
@@ -103,6 +142,13 @@ const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
103
142
  const isObject = (v: unknown): v is object =>
104
143
  typeof v === 'object' && v !== null;
105
144
 
145
+ /** Whether `v` is a plain object (as devalue revives one). */
146
+ const isPlainData = (v: unknown): v is Record<string, unknown> =>
147
+ isObject(v) && Object.getPrototypeOf(v) === Object.prototype;
148
+
149
+ const malformedClass = (name: string): TypeError =>
150
+ new TypeError(`spindle: Malformed data for class "${name}"`);
151
+
106
152
  const defineData = (target: object, key: string, value: unknown): void => {
107
153
  // Define, so that a "__proto__" key stays a key (devalue refuses it)
108
154
  Object.defineProperty(target, key, {
@@ -127,7 +173,10 @@ function refuseSymbolKeys(value: object): void {
127
173
  * Own enumerable keys of `value`, as a plain object; for an error also its
128
174
  * message, cause and (AggregateError) errors, which are not enumerable.
129
175
  */
130
- function ownData(value: object): Record<string, unknown> {
176
+ function ownData(
177
+ value: object,
178
+ keys = Object.keys(value),
179
+ ): Record<string, unknown> {
131
180
  refuseSymbolKeys(value);
132
181
  const data: Record<string, unknown> = {};
133
182
  if (value instanceof Error) {
@@ -135,12 +184,58 @@ function ownData(value: object): Record<string, unknown> {
135
184
  if (hasOwn(value, key)) defineData(data, key, value[key as keyof Error]);
136
185
  }
137
186
  }
138
- for (const key of Object.keys(value)) {
187
+ for (const key of keys) {
139
188
  defineData(data, key, (value as Record<string, unknown>)[key]);
140
189
  }
141
190
  return data;
142
191
  }
143
192
 
193
+ /**
194
+ * The built-ins a registered class may extend whose value is not in its
195
+ * keys, saved as their kind (see classData).
196
+ */
197
+ const BUILTIN_KINDS = {
198
+ Array: Array as unknown as Constructor,
199
+ Map: Map as Constructor,
200
+ Set: Set as Constructor,
201
+ Date: Date as Constructor,
202
+ RegExp: RegExp as Constructor,
203
+ };
204
+ type BuiltinKind = keyof typeof BUILTIN_KINDS;
205
+
206
+ /**
207
+ * The saved form of a registered class instance: its own data (see
208
+ * ownData), or for a subclass of a built-in kind (see BUILTIN_KINDS) its
209
+ * kind, contents and extra keys, read with the built-in methods, which a
210
+ * subclass may override (#391, #393).
211
+ */
212
+ function classData(value: object): unknown {
213
+ const extra = () => ownData(value, extraKeys(value));
214
+ if (Array.isArray(value)) {
215
+ // Holes stay holes
216
+ const items = new Array(value.length) as unknown[];
217
+ for (let i = 0; i < value.length; i++) {
218
+ if (i in value) items[i] = value[i] as unknown;
219
+ }
220
+ return ['Array', items, extra()];
221
+ }
222
+ if (value instanceof Map) {
223
+ return ['Map', Array.from(mapEntries(value)), extra()];
224
+ }
225
+ if (value instanceof Set) {
226
+ return ['Set', Array.from(setMembers(value)), extra()];
227
+ }
228
+ if (value instanceof Date) {
229
+ return ['Date', Date.prototype.getTime.call(value), extra()];
230
+ }
231
+ if (value instanceof RegExp) {
232
+ const source = Reflect.get(RegExp.prototype, 'source', value) as string;
233
+ const flags = Reflect.get(RegExp.prototype, 'flags', value) as string;
234
+ return ['RegExp', [source, flags, value.lastIndex], extra()];
235
+ }
236
+ return ownData(value);
237
+ }
238
+
144
239
  /** Make an error's message, cause and errors non-enumerable again. */
145
240
  function hideErrorKeys(value: object): void {
146
241
  for (const key of ERROR_HIDDEN_KEYS) {
@@ -158,7 +253,9 @@ function reducers(): Record<string, (value: unknown) => unknown> {
158
253
  const out: Record<string, (value: unknown) => unknown> = {};
159
254
  for (const [name, ctor] of registry) {
160
255
  out[CLASS_PREFIX + name] = (v) =>
161
- isObject(v) && Object.getPrototypeOf(v) === ctor.prototype && ownData(v);
256
+ isObject(v) &&
257
+ Object.getPrototypeOf(v) === ctor.prototype &&
258
+ classData(v);
162
259
  }
163
260
  for (const [proto, name] of BUILTIN_ERRORS) {
164
261
  out[ERROR_PREFIX + name] = (v) =>
@@ -175,7 +272,7 @@ function reducers(): Record<string, (value: unknown) => unknown> {
175
272
  v.lastIndex !== 0 && [v.source, v.flags, v.lastIndex];
176
273
  if (GUARD_V8_KEYS) {
177
274
  out[KEYS_TAG] = (v) => {
178
- if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) {
275
+ if (!isPlainData(v)) {
179
276
  return false;
180
277
  }
181
278
  const keys = Object.keys(v);
@@ -192,19 +289,108 @@ function reducers(): Record<string, (value: unknown) => unknown> {
192
289
  return out;
193
290
  }
194
291
 
292
+ /**
293
+ * The RegExp of a `[source, flags, lastIndex]` list (an `R` entry, or the
294
+ * contents of a RegExp subclass), or undefined if the list is malformed.
295
+ */
296
+ function newRegExp(data: unknown): RegExp | undefined {
297
+ if (
298
+ !Array.isArray(data) ||
299
+ typeof data[0] !== 'string' ||
300
+ typeof data[1] !== 'string' ||
301
+ !Number.isSafeInteger(data[2])
302
+ ) {
303
+ return undefined;
304
+ }
305
+ const re = new RegExp(data[0], data[1]);
306
+ re.lastIndex = data[2] as number;
307
+ return re;
308
+ }
309
+
310
+ /**
311
+ * A new Date or RegExp holding `contents` (see classData), or undefined if
312
+ * they are malformed.
313
+ */
314
+ function newAtomic(kind: 'Date' | 'RegExp', contents: unknown) {
315
+ if (kind === 'RegExp') return newRegExp(contents);
316
+ return typeof contents === 'number' ? new Date(contents) : undefined;
317
+ }
318
+
319
+ /**
320
+ * The built-in of a registered class that a `[kind, contents, keys]` list
321
+ * holds (see classData). A cycle through a collection revives it before its
322
+ * list is complete (devalue holds unread parts as holes), so the instance
323
+ * is kept per list and filled with what the list holds so far. (The
324
+ * contents of a Date or RegExp hold no objects, so they are complete.)
325
+ */
326
+ const collections = new WeakMap<unknown[], object>();
327
+ function reviveBuiltin(name: string, ctor: Constructor, list: unknown[]) {
328
+ const [kind, contents, keys] = list;
329
+ const base =
330
+ typeof kind === 'string' && hasOwn(BUILTIN_KINDS, kind)
331
+ ? BUILTIN_KINDS[kind as BuiltinKind]
332
+ : undefined;
333
+ const atomic = kind === 'Date' || kind === 'RegExp';
334
+ if (
335
+ list.length !== 3 ||
336
+ !base ||
337
+ !(ctor.prototype instanceof base) ||
338
+ (!atomic && hasOwn(list, 1) && !Array.isArray(contents)) ||
339
+ (hasOwn(list, 2) && !isPlainData(keys))
340
+ ) {
341
+ throw malformedClass(name);
342
+ }
343
+ let made = collections.get(list);
344
+ if (!made) {
345
+ const value = atomic ? newAtomic(kind, contents) : (new base() as object);
346
+ if (!value) throw malformedClass(name);
347
+ made = Object.setPrototypeOf(value, ctor.prototype) as object;
348
+ collections.set(list, made);
349
+ }
350
+ if (!atomic && Array.isArray(contents)) fillCollection(made, contents);
351
+ if (isPlainData(keys)) {
352
+ for (const key of Object.keys(keys)) defineData(made, key, keys[key]);
353
+ }
354
+ return made;
355
+ }
356
+
357
+ /** Set the elements of a revived collection, with the built-in methods. */
358
+ function fillCollection(made: object, contents: unknown[]): void {
359
+ if (Array.isArray(made)) {
360
+ made.length = contents.length;
361
+ for (let i = 0; i < contents.length; i++) {
362
+ if (hasOwn(contents, i)) made[i] = contents[i];
363
+ else delete made[i];
364
+ }
365
+ } else if (made instanceof Map) {
366
+ Map.prototype.clear.call(made);
367
+ for (const entry of contents) {
368
+ if (!Array.isArray(entry) || entry.length !== 2) {
369
+ throw new TypeError('spindle: Malformed map entries');
370
+ }
371
+ Map.prototype.set.call(made, entry[0], entry[1]);
372
+ }
373
+ } else {
374
+ Set.prototype.clear.call(made);
375
+ for (const member of contents) Set.prototype.add.call(made, member);
376
+ }
377
+ }
378
+
195
379
  /** Restore a registered class instance in place, so cycles through it hold. */
196
380
  function reviveClass(name: string) {
197
- return (data: Record<string, unknown>): unknown => {
198
- if (!isObject(data) || Object.getPrototypeOf(data) !== Object.prototype) {
381
+ return (data: Record<string, unknown> | unknown[]): unknown => {
382
+ if (Array.isArray(data)) {
383
+ return reviveBuiltin(name, registeredCtor(name), data);
384
+ }
385
+ if (!isPlainData(data)) {
199
386
  // Already revived: a cycle through the instance revives it twice
200
387
  if (isObject(data) && registeredClassName(data) === name) return data;
201
- throw new TypeError(`spindle: Malformed data for class "${name}"`);
388
+ throw malformedClass(name);
202
389
  }
203
- const ctor = registry.get(name);
204
- if (!ctor) {
205
- throw new TypeError(
206
- `spindle: The save holds an instance of class "${name}", which is not registered`,
207
- );
390
+ const ctor = registeredCtor(name);
391
+ // A plain object cannot be made a built-in (see classData)
392
+ if (Object.values(BUILTIN_KINDS).some((c) => ctor.prototype instanceof c)) {
393
+ throw malformedClass(name);
208
394
  }
209
395
  Object.setPrototypeOf(data, ctor.prototype as object);
210
396
  if (data instanceof Error) hideErrorKeys(data);
@@ -212,13 +398,24 @@ function reviveClass(name: string) {
212
398
  };
213
399
  }
214
400
 
401
+ /** The class registered as `name`; throws if there is none. */
402
+ function registeredCtor(name: string): Constructor {
403
+ const ctor = registry.get(name);
404
+ if (!ctor) {
405
+ throw new TypeError(
406
+ `spindle: The save holds an instance of class "${name}", which is not registered`,
407
+ );
408
+ }
409
+ return ctor;
410
+ }
411
+
215
412
  /** Restore a built-in error in place, like a class instance. */
216
413
  function reviveError(name: string) {
217
414
  const ctor = ERROR_CTORS.get(name);
218
415
  if (!ctor) return undefined;
219
416
  return (data: Record<string, unknown>): unknown => {
220
417
  if (Object.getPrototypeOf(data) === ctor.prototype) return data;
221
- if (!isObject(data) || Object.getPrototypeOf(data) !== Object.prototype) {
418
+ if (!isPlainData(data)) {
222
419
  throw new TypeError(`spindle: Malformed data for "${name}"`);
223
420
  }
224
421
  Object.setPrototypeOf(data, ctor.prototype);
@@ -272,16 +469,8 @@ function reviverFor(key: string | symbol) {
272
469
  if (key === KEYS_TAG) return reviveKeyed;
273
470
  if (key === REGEXP_TAG) {
274
471
  return (data: unknown) => {
275
- if (
276
- !Array.isArray(data) ||
277
- typeof data[0] !== 'string' ||
278
- typeof data[1] !== 'string' ||
279
- !Number.isSafeInteger(data[2])
280
- ) {
281
- throw new TypeError('spindle: Malformed regular expression');
282
- }
283
- const re = new RegExp(data[0], data[1]);
284
- re.lastIndex = data[2] as number;
472
+ const re = newRegExp(data);
473
+ if (!re) throw new TypeError('spindle: Malformed regular expression');
285
474
  return re;
286
475
  };
287
476
  }
@@ -7,7 +7,12 @@ import {
7
7
  } from 'preact/hooks';
8
8
  import { parseMarkup } from '../markup/parse';
9
9
  import type { ASTNode } from '../markup/ast';
10
- import { renderNodes, NobrContext } from '../markup/render';
10
+ import {
11
+ renderNodes,
12
+ NobrContext,
13
+ ViewScopeContext,
14
+ useViewScope,
15
+ } from '../markup/render';
11
16
  import { useStoryFields } from '../hooks/use-story-fields';
12
17
  import type { Passage as PassageData } from '../parser';
13
18
  import { sourceLocationOf } from '../utils/source-location';
@@ -130,20 +135,23 @@ export function Passage({
130
135
  }, [passage.name, navigationId]);
131
136
 
132
137
  const nobr = passage.tags.includes('nobr');
138
+ const viewScope = useViewScope();
133
139
 
134
140
  const inner = (
135
- <div
136
- ref={elRef}
137
- class="passage"
138
- data-passage={passage.name}
139
- data-tags={passage.tags.join(' ')}
140
- data-transition={dataTransition}
141
- >
142
- {headerContent && <div class="passage-header">{headerContent}</div>}
143
- {content}
144
- {footerContent && <div class="passage-footer">{footerContent}</div>}
145
- {doneContent && <div hidden>{doneContent}</div>}
146
- </div>
141
+ <ViewScopeContext.Provider value={viewScope}>
142
+ <div
143
+ ref={elRef}
144
+ class="passage"
145
+ data-passage={passage.name}
146
+ data-tags={passage.tags.join(' ')}
147
+ data-transition={dataTransition}
148
+ >
149
+ {headerContent && <div class="passage-header">{headerContent}</div>}
150
+ {content}
151
+ {footerContent && <div class="passage-footer">{footerContent}</div>}
152
+ {doneContent && <div hidden>{doneContent}</div>}
153
+ </div>
154
+ </ViewScopeContext.Provider>
147
155
  );
148
156
 
149
157
  return nobr ? (
@@ -1,7 +1,12 @@
1
1
  import { createContext } from 'preact';
2
2
  import { useCallback, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
3
3
  import { parseMarkup } from '../markup/parse';
4
- import { renderNodes, NobrContext } from '../markup/render';
4
+ import {
5
+ renderNodes,
6
+ NobrContext,
7
+ ViewScopeContext,
8
+ useViewScope,
9
+ } from '../markup/render';
5
10
  import { useStoryFields } from '../hooks/use-story-fields';
6
11
  import { emitFromRender } from '../event-emitter';
7
12
  import { useModalFocus } from '../hooks/use-modal-focus';
@@ -66,6 +71,7 @@ export function PassageDialog({
66
71
  }, [markup, nobr]);
67
72
 
68
73
  const panelRef = useRef<HTMLDivElement>(null);
74
+ const viewScope = useViewScope();
69
75
 
70
76
  // Focus into the dialog, trap Tab, Escape to close, restore focus on close.
71
77
  // Declared before the dialogrender effect so handlers can move focus.
@@ -113,7 +119,11 @@ export function PassageDialog({
113
119
  ✕
114
120
  </button>
115
121
  )}
116
- <div class="dialog-body">{content}</div>
122
+ <div class="dialog-body">
123
+ <ViewScopeContext.Provider value={viewScope}>
124
+ {content}
125
+ </ViewScopeContext.Provider>
126
+ </div>
117
127
  </div>
118
128
  </div>
119
129
  </DialogCloseContext.Provider>
@@ -1,4 +1,5 @@
1
1
  import { defineMacro } from '../../define-macro';
2
+ import { ViewScopeContext } from '../../markup/render';
2
3
  import { VARIABLE_PARAMETER, useVariableAction } from './input-macro';
3
4
 
4
5
  defineMacro({
@@ -12,6 +13,8 @@ defineMacro({
12
13
  render(_props, ctx) {
13
14
  const radioValue = ctx.args.value ?? '';
14
15
  const label = ctx.args.label ?? '';
16
+ // One group per variable in each view (see ViewScopeContext)
17
+ const view = ctx.hooks.useContext(ViewScopeContext);
15
18
 
16
19
  useVariableAction(ctx, {
17
20
  type: 'radiobutton',
@@ -27,7 +30,7 @@ defineMacro({
27
30
  >
28
31
  <input
29
32
  type="radio"
30
- name={`radio-${ctx.varName}`}
33
+ name={view ? `radio-${view}-${ctx.varName}` : `radio-${ctx.varName}`}
31
34
  checked={ctx.value === radioValue}
32
35
  onChange={() => ctx.setValue!(radioValue)}
33
36
  />
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'preact/hooks';
1
+ import { useContext, useMemo } from 'preact/hooks';
2
2
  import { WidgetChildrenContext, renderNodes } from '../../markup/render';
3
3
  import { useMergedLocals } from '../../hooks/use-merged-locals';
4
4
  import { evaluate } from '../../expression';
@@ -9,6 +9,7 @@ import { useRenderOptions } from '../../hooks/use-render-options';
9
9
  import { LocalsScope } from './locals-scope';
10
10
  import { useInterpolate } from '../../hooks/use-interpolate';
11
11
  import { wrapContent } from './display';
12
+ import { widgetChildrenOf } from '../../interpolation';
12
13
 
13
14
  export { splitArgs };
14
15
 
@@ -44,7 +45,11 @@ function WidgetContent({
44
45
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] =
45
46
  useMergedLocals();
46
47
 
47
- const childrenValue = invocationChildren?.length ? invocationChildren : null;
48
+ const outerChildren = useContext(WidgetChildrenContext);
49
+ const childrenValue = useMemo(
50
+ () => widgetChildrenOf(invocationChildren, outerChildren),
51
+ [invocationChildren, outerChildren],
52
+ );
48
53
 
49
54
  // Every widget gets its own local scope, even when it declares no
50
55
  // parameters or is invoked without arguments: missing parameters shadow
@@ -26,6 +26,7 @@ import {
26
26
  } from './structural';
27
27
  import { getByPath, setByPath } from './utils/object-path';
28
28
  import { asNamespace, hasOwn } from './utils/namespace';
29
+ import { mapEntries, setMembers } from './utils/value-kinds';
29
30
 
30
31
  type NamespaceName = keyof VariableNamespaces;
31
32
 
@@ -148,6 +149,16 @@ function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
148
149
  if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
149
150
  if (!Object.isFrozen(value)) return false;
150
151
  seen.add(value);
152
+ // What a Map or Set holds is not in its properties (#388)
153
+ if (value instanceof Map) {
154
+ for (const [key, entry] of mapEntries(value)) {
155
+ if (!deeplyFrozen(key, seen) || !deeplyFrozen(entry, seen)) return false;
156
+ }
157
+ } else if (value instanceof Set) {
158
+ for (const member of setMembers(value)) {
159
+ if (!deeplyFrozen(member, seen)) return false;
160
+ }
161
+ }
151
162
  return Reflect.ownKeys(value).every((key) => {
152
163
  const desc = Object.getOwnPropertyDescriptor(value, key);
153
164
  return !desc || !('value' in desc) || deeplyFrozen(desc.value, seen);
@@ -14,10 +14,26 @@ const FOCUSABLE = [
14
14
  '[tabindex]',
15
15
  ].join(',');
16
16
 
17
+ /**
18
+ * Whether `el` is in a closed `<details>`, other than in the summary that
19
+ * toggles it: the browser renders none of that content (#389).
20
+ */
21
+ function inClosedDetails(el: HTMLElement): boolean {
22
+ for (let e = el.parentElement; e; e = e.parentElement) {
23
+ if (e.localName !== 'details' || e.hasAttribute('open')) continue;
24
+ const summary = Array.from(e.children).find(
25
+ (c) => c.localName === 'summary',
26
+ );
27
+ if (!summary?.contains(el)) return true;
28
+ }
29
+ return false;
30
+ }
31
+
17
32
  /**
18
33
  * Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
19
- * (inherited, so the computed value covers ancestors) and not inside a
20
- * `display: none` element.
34
+ * (inherited, so the computed value covers ancestors), not inside a
35
+ * `display: none` element and not in the hidden content of a closed
36
+ * `<details>`.
21
37
  */
22
38
  function isRendered(el: HTMLElement): boolean {
23
39
  const { visibility } = getComputedStyle(el);
@@ -25,7 +41,21 @@ function isRendered(el: HTMLElement): boolean {
25
41
  for (let e: HTMLElement | null = el; e; e = e.parentElement) {
26
42
  if (getComputedStyle(e).display === 'none') return false;
27
43
  }
28
- return true;
44
+ return !inClosedDetails(el);
45
+ }
46
+
47
+ /**
48
+ * Whether `el` can be focused at all: shown, rendered, enabled (also by its
49
+ * fieldset) and not inert.
50
+ */
51
+ function isAvailable(el: HTMLElement): boolean {
52
+ return (
53
+ !el.hidden &&
54
+ !el.closest('[hidden], [inert]') &&
55
+ // Also covers controls disabled by a <fieldset disabled> ancestor
56
+ !el.matches(':disabled') &&
57
+ isRendered(el)
58
+ );
29
59
  }
30
60
 
31
61
  /** The `tabindex` of `el` as an integer, or null when absent or invalid. */
@@ -36,9 +66,10 @@ function tabIndexAttr(el: HTMLElement): number | null {
36
66
  }
37
67
 
38
68
  /**
39
- * Whether the tab key can reach `el`: a negative tabindex only allows
40
- * scripted focus, and of a radio group the browser tabs to the checked
41
- * member only (the first one when none is checked).
69
+ * Whether the tab key can reach `el`, an available element (see
70
+ * isAvailable): a negative tabindex only allows scripted focus, and of a
71
+ * radio group the browser tabs to the checked member only (the first one
72
+ * when none is checked), of the members it can reach.
42
73
  */
43
74
  function isTabStop(el: HTMLElement): boolean {
44
75
  const tabIndex = tabIndexAttr(el);
@@ -67,7 +98,14 @@ function isTabStop(el: HTMLElement): boolean {
67
98
  (
68
99
  el.form ?? (el.getRootNode() as ParentNode)
69
100
  ).querySelectorAll<HTMLInputElement>('input[type="radio"]'),
70
- ).filter((r) => r.name === el.name && r.form === el.form && !r.disabled);
101
+ ).filter(
102
+ (r) =>
103
+ r.name === el.name &&
104
+ r.form === el.form &&
105
+ // Of the members Tab can reach (#390)
106
+ (tabIndexAttr(r) ?? 0) >= 0 &&
107
+ isAvailable(r),
108
+ );
71
109
  const stop = group.find((r) => r.checked) ?? group[0];
72
110
  return el === stop;
73
111
  }
@@ -81,15 +119,7 @@ function isTabStop(el: HTMLElement): boolean {
81
119
  function focusables(root: HTMLElement): HTMLElement[] {
82
120
  const stops = Array.from(
83
121
  root.querySelectorAll<HTMLElement>(FOCUSABLE),
84
- ).filter(
85
- (el) =>
86
- !el.hidden &&
87
- !el.closest('[hidden], [inert]') &&
88
- // Also covers controls disabled by a <fieldset disabled> ancestor
89
- !el.matches(':disabled') &&
90
- isRendered(el) &&
91
- isTabStop(el),
92
- );
122
+ ).filter((el) => isAvailable(el) && isTabStop(el));
93
123
  const positive = (el: HTMLElement) => Math.max(tabIndexAttr(el) ?? 0, 0);
94
124
  // Array.prototype.sort is stable: equal tabindexes keep document order
95
125
  return [
@@ -40,6 +40,25 @@ export function hasInterpolation(s: string): boolean {
40
40
  return s.includes('{') || s.includes('}');
41
41
  }
42
42
 
43
+ /**
44
+ * The children an invocation passes to a block widget, for `{@children}`.
45
+ * `outer` is the children context the invocation itself was written in: a
46
+ * `{@children}` among `nodes` forwards the enclosing widget's children, so it
47
+ * resolves there, not against `nodes` again (#386).
48
+ */
49
+ export interface WidgetChildren {
50
+ readonly nodes: ASTNode[];
51
+ readonly outer: WidgetChildren | null;
52
+ }
53
+
54
+ /** The children context for an invocation's `children`, or null if none. */
55
+ export function widgetChildrenOf(
56
+ children: ASTNode[] | undefined,
57
+ outer: WidgetChildren | null | undefined,
58
+ ): WidgetChildren | null {
59
+ return children?.length ? { nodes: children, outer: outer ?? null } : null;
60
+ }
61
+
43
62
  /** The scope text-only markup is evaluated in. */
44
63
  export interface TextScope {
45
64
  variables: Record<string, unknown>;
@@ -47,7 +66,7 @@ export interface TextScope {
47
66
  locals: Record<string, unknown>;
48
67
  transient: Record<string, unknown>;
49
68
  /** Invocation children of the enclosing block widget, for `{@children}`. */
50
- widgetChildren?: ASTNode[] | null;
69
+ widgetChildren?: WidgetChildren | null;
51
70
  }
52
71
 
53
72
  /** A macro, expression or markup error met while evaluating text. */
@@ -177,11 +196,12 @@ class TextEvaluator {
177
196
  case 'variable':
178
197
  if (node.scope === 'local' && node.name === 'children') {
179
198
  // As {@children} in a widget body: the invocation's children, in
180
- // the scope of the body.
199
+ // the scope of the body, and in the children context they were
200
+ // written in.
181
201
  const children = scope.widgetChildren;
182
202
  if (!children) return '';
183
- return this.nested('children', children, scope, depth, {
184
- widgetChildren: null,
203
+ return this.nested('children', children.nodes, scope, depth, {
204
+ widgetChildren: children.outer,
185
205
  });
186
206
  }
187
207
  return this.variable(node.scope, node.name, scope);
@@ -262,7 +282,7 @@ class TextEvaluator {
262
282
  }
263
283
  return this.nested(node.name, widget.body, scope, depth, {
264
284
  locals,
265
- widgetChildren: node.children.length > 0 ? node.children : null,
285
+ widgetChildren: widgetChildrenOf(node.children, scope.widgetChildren),
266
286
  });
267
287
  }
268
288