@rohal12/spindle 0.59.12 → 0.59.14

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.
@@ -10431,28 +10431,34 @@ function parseWidgetDef(rawArgs) {
10431
10431
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
10432
10432
  }
10433
10433
  /**
10434
- * A definition: its arguments (read like a {widget} macro's, see
10435
- * parseWidgetDef) and its body. Macro names are not case-sensitive.
10436
- */
10437
- var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
10438
- /** `{@children}`, also with selectors such as `{.highlight @children}`. */
10439
- var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
10440
- /**
10441
10434
  * The names of the block widgets (those whose body renders `{@children}`)
10442
10435
  * that StoryInit and the passages tagged `widget` define. They must be
10443
10436
  * known as block macros before any passage is parsed, so that passages
10444
10437
  * invoking them nest their content whatever the passage order.
10438
+ *
10439
+ * The passages are read as flat tokens, which need no block macros to be
10440
+ * known, so a `{@children}` in an HTML comment or in a `{do}` body is text
10441
+ * here as it is to the parser, and does not make a block widget (#387).
10445
10442
  */
10446
10443
  function blockWidgetNames(passages) {
10447
10444
  const names = [];
10448
10445
  for (const passage of passages) {
10449
10446
  if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
10450
- for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
10451
- if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
10447
+ const open = [];
10448
+ for (const token of tokenizeMarkupTolerant(passage.content).tokens) if (token.type === "macro" && token.name.toLowerCase() === "widget") {
10449
+ if (!token.isClose) {
10450
+ open.push({
10451
+ rawArgs: token.rawArgs,
10452
+ isBlock: false
10453
+ });
10454
+ continue;
10455
+ }
10456
+ const def = open.pop();
10457
+ if (!def?.isBlock) continue;
10452
10458
  try {
10453
- names.push(parseWidgetDef(match[1]).name);
10459
+ names.push(parseWidgetDef(def.rawArgs).name);
10454
10460
  } catch {}
10455
- }
10461
+ } else if (token.type === "variable" && token.scope === "local" && token.name === "children" && open.length > 0) open[open.length - 1].isBlock = true;
10456
10462
  }
10457
10463
  return names;
10458
10464
  }
@@ -11082,7 +11088,8 @@ var VALID_VAR_TYPES = new Set([
11082
11088
  ]);
11083
11089
  function inferSchema(value) {
11084
11090
  if (Array.isArray(value)) return { type: "array" };
11085
- if (value !== null && typeof value === "object") {
11091
+ if (value === null) return { type: "null" };
11092
+ if (typeof value === "object") {
11086
11093
  const fields = /* @__PURE__ */ new Map();
11087
11094
  for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
11088
11095
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.12",
3
+ "version": "0.59.14",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { parse, stringify } from 'devalue';
4
4
  import { hasOwn } from './utils/namespace';
5
+ import { extraKeys } from './utils/value-kinds';
5
6
 
6
7
  type Constructor = new (...args: any[]) => any;
7
8
 
@@ -46,7 +47,10 @@ export function clearRegistry(): void {
46
47
  // values. Spindle adds, with reducers and revivers:
47
48
  //
48
49
  // - `c:<name>`: an instance of the class registered as <name>, with its own
49
- // enumerable keys (and, for Error subclasses, its message and cause);
50
+ // enumerable keys (and, for Error subclasses, its message and cause); for
51
+ // a subclass of Array, Map or Set, `[kind, contents, keys]`: the kind of
52
+ // collection, its elements (Map entries as pairs) and its other own
53
+ // enumerable keys;
50
54
  // - `E:<name>`: a built-in error (Error, TypeError, ..., AggregateError),
51
55
  // with its message, cause, errors and own enumerable keys (not its stack);
52
56
  // - `S`: a symbol from the global registry (Symbol.for);
@@ -103,6 +107,13 @@ const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
103
107
  const isObject = (v: unknown): v is object =>
104
108
  typeof v === 'object' && v !== null;
105
109
 
110
+ /** Whether `v` is a plain object (as devalue revives one). */
111
+ const isPlainData = (v: unknown): v is Record<string, unknown> =>
112
+ isObject(v) && Object.getPrototypeOf(v) === Object.prototype;
113
+
114
+ const malformedClass = (name: string): TypeError =>
115
+ new TypeError(`spindle: Malformed data for class "${name}"`);
116
+
106
117
  const defineData = (target: object, key: string, value: unknown): void => {
107
118
  // Define, so that a "__proto__" key stays a key (devalue refuses it)
108
119
  Object.defineProperty(target, key, {
@@ -127,7 +138,10 @@ function refuseSymbolKeys(value: object): void {
127
138
  * Own enumerable keys of `value`, as a plain object; for an error also its
128
139
  * message, cause and (AggregateError) errors, which are not enumerable.
129
140
  */
130
- function ownData(value: object): Record<string, unknown> {
141
+ function ownData(
142
+ value: object,
143
+ keys = Object.keys(value),
144
+ ): Record<string, unknown> {
131
145
  refuseSymbolKeys(value);
132
146
  const data: Record<string, unknown> = {};
133
147
  if (value instanceof Error) {
@@ -135,12 +149,44 @@ function ownData(value: object): Record<string, unknown> {
135
149
  if (hasOwn(value, key)) defineData(data, key, value[key as keyof Error]);
136
150
  }
137
151
  }
138
- for (const key of Object.keys(value)) {
152
+ for (const key of keys) {
139
153
  defineData(data, key, (value as Record<string, unknown>)[key]);
140
154
  }
141
155
  return data;
142
156
  }
143
157
 
158
+ /** The kinds of collection a registered class may extend. */
159
+ const COLLECTION_KINDS = {
160
+ Array: Array as unknown as Constructor,
161
+ Map: Map as Constructor,
162
+ Set: Set as Constructor,
163
+ };
164
+ type CollectionKind = keyof typeof COLLECTION_KINDS;
165
+
166
+ /**
167
+ * The saved form of a registered class instance: its own data (see
168
+ * ownData), or for a collection its kind, elements and extra keys, read
169
+ * with the built-in methods, which a subclass may override (#391).
170
+ */
171
+ function classData(value: object): unknown {
172
+ const extra = () => ownData(value, extraKeys(value));
173
+ if (Array.isArray(value)) {
174
+ // Holes stay holes
175
+ const items = new Array(value.length) as unknown[];
176
+ for (let i = 0; i < value.length; i++) {
177
+ if (i in value) items[i] = value[i] as unknown;
178
+ }
179
+ return ['Array', items, extra()];
180
+ }
181
+ if (value instanceof Map) {
182
+ return ['Map', Array.from(Map.prototype.entries.call(value)), extra()];
183
+ }
184
+ if (value instanceof Set) {
185
+ return ['Set', Array.from(Set.prototype.values.call(value)), extra()];
186
+ }
187
+ return ownData(value);
188
+ }
189
+
144
190
  /** Make an error's message, cause and errors non-enumerable again. */
145
191
  function hideErrorKeys(value: object): void {
146
192
  for (const key of ERROR_HIDDEN_KEYS) {
@@ -158,7 +204,9 @@ function reducers(): Record<string, (value: unknown) => unknown> {
158
204
  const out: Record<string, (value: unknown) => unknown> = {};
159
205
  for (const [name, ctor] of registry) {
160
206
  out[CLASS_PREFIX + name] = (v) =>
161
- isObject(v) && Object.getPrototypeOf(v) === ctor.prototype && ownData(v);
207
+ isObject(v) &&
208
+ Object.getPrototypeOf(v) === ctor.prototype &&
209
+ classData(v);
162
210
  }
163
211
  for (const [proto, name] of BUILTIN_ERRORS) {
164
212
  out[ERROR_PREFIX + name] = (v) =>
@@ -175,7 +223,7 @@ function reducers(): Record<string, (value: unknown) => unknown> {
175
223
  v.lastIndex !== 0 && [v.source, v.flags, v.lastIndex];
176
224
  if (GUARD_V8_KEYS) {
177
225
  out[KEYS_TAG] = (v) => {
178
- if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) {
226
+ if (!isPlainData(v)) {
179
227
  return false;
180
228
  }
181
229
  const keys = Object.keys(v);
@@ -192,19 +240,79 @@ function reducers(): Record<string, (value: unknown) => unknown> {
192
240
  return out;
193
241
  }
194
242
 
243
+ /**
244
+ * The collection of a registered class that a `[kind, contents, keys]`
245
+ * list holds (see classData). A cycle through the collection revives it
246
+ * before its list is complete (devalue holds unread parts as holes), so the
247
+ * collection is kept per list and filled with what the list holds so far.
248
+ */
249
+ const collections = new WeakMap<unknown[], object>();
250
+ function reviveCollection(name: string, ctor: Constructor, list: unknown[]) {
251
+ const [kind, contents, keys] = list;
252
+ const base =
253
+ typeof kind === 'string' && hasOwn(COLLECTION_KINDS, kind)
254
+ ? COLLECTION_KINDS[kind as CollectionKind]
255
+ : undefined;
256
+ if (
257
+ list.length !== 3 ||
258
+ !base ||
259
+ !(ctor.prototype instanceof base) ||
260
+ (hasOwn(list, 1) && !Array.isArray(contents)) ||
261
+ (hasOwn(list, 2) && !isPlainData(keys))
262
+ ) {
263
+ throw malformedClass(name);
264
+ }
265
+ let made = collections.get(list);
266
+ if (!made) {
267
+ made = Object.setPrototypeOf(new base(), ctor.prototype) as object;
268
+ collections.set(list, made);
269
+ }
270
+ if (Array.isArray(contents)) fillCollection(made, contents);
271
+ if (isPlainData(keys)) {
272
+ for (const key of Object.keys(keys)) defineData(made, key, keys[key]);
273
+ }
274
+ return made;
275
+ }
276
+
277
+ /** Set the elements of a revived collection, with the built-in methods. */
278
+ function fillCollection(made: object, contents: unknown[]): void {
279
+ if (Array.isArray(made)) {
280
+ made.length = contents.length;
281
+ for (let i = 0; i < contents.length; i++) {
282
+ if (hasOwn(contents, i)) made[i] = contents[i];
283
+ else delete made[i];
284
+ }
285
+ } else if (made instanceof Map) {
286
+ Map.prototype.clear.call(made);
287
+ for (const entry of contents) {
288
+ if (!Array.isArray(entry) || entry.length !== 2) {
289
+ throw new TypeError('spindle: Malformed map entries');
290
+ }
291
+ Map.prototype.set.call(made, entry[0], entry[1]);
292
+ }
293
+ } else {
294
+ Set.prototype.clear.call(made);
295
+ for (const member of contents) Set.prototype.add.call(made, member);
296
+ }
297
+ }
298
+
195
299
  /** Restore a registered class instance in place, so cycles through it hold. */
196
300
  function reviveClass(name: string) {
197
- return (data: Record<string, unknown>): unknown => {
198
- if (!isObject(data) || Object.getPrototypeOf(data) !== Object.prototype) {
301
+ return (data: Record<string, unknown> | unknown[]): unknown => {
302
+ if (Array.isArray(data)) {
303
+ return reviveCollection(name, registeredCtor(name), data);
304
+ }
305
+ if (!isPlainData(data)) {
199
306
  // Already revived: a cycle through the instance revives it twice
200
307
  if (isObject(data) && registeredClassName(data) === name) return data;
201
- throw new TypeError(`spindle: Malformed data for class "${name}"`);
308
+ throw malformedClass(name);
202
309
  }
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
- );
310
+ const ctor = registeredCtor(name);
311
+ // A plain object cannot be made a collection (see classData)
312
+ if (
313
+ Object.values(COLLECTION_KINDS).some((c) => ctor.prototype instanceof c)
314
+ ) {
315
+ throw malformedClass(name);
208
316
  }
209
317
  Object.setPrototypeOf(data, ctor.prototype as object);
210
318
  if (data instanceof Error) hideErrorKeys(data);
@@ -212,13 +320,24 @@ function reviveClass(name: string) {
212
320
  };
213
321
  }
214
322
 
323
+ /** The class registered as `name`; throws if there is none. */
324
+ function registeredCtor(name: string): Constructor {
325
+ const ctor = registry.get(name);
326
+ if (!ctor) {
327
+ throw new TypeError(
328
+ `spindle: The save holds an instance of class "${name}", which is not registered`,
329
+ );
330
+ }
331
+ return ctor;
332
+ }
333
+
215
334
  /** Restore a built-in error in place, like a class instance. */
216
335
  function reviveError(name: string) {
217
336
  const ctor = ERROR_CTORS.get(name);
218
337
  if (!ctor) return undefined;
219
338
  return (data: Record<string, unknown>): unknown => {
220
339
  if (Object.getPrototypeOf(data) === ctor.prototype) return data;
221
- if (!isObject(data) || Object.getPrototypeOf(data) !== Object.prototype) {
340
+ if (!isPlainData(data)) {
222
341
  throw new TypeError(`spindle: Malformed data for "${name}"`);
223
342
  }
224
343
  Object.setPrototypeOf(data, ctor.prototype);
@@ -5,7 +5,7 @@ import { deepEqualStrict } from '../../structural';
5
5
  import { defineMacro } from '../../define-macro';
6
6
  import { MacroError, logMacroError } from './MacroError';
7
7
  import { currentSourceLocation } from '../../utils/source-location';
8
- import { checkVariableName } from '../../utils/namespace';
8
+ import { checkVariableName, ownValue } from '../../utils/namespace';
9
9
 
10
10
  /**
11
11
  * The previous output before the first successful evaluation. Distinct from
@@ -39,7 +39,7 @@ function assignmentArgs(
39
39
  /**
40
40
  * Evaluate `expr` against the current values — the store's state and the
41
41
  * enclosing locals scope's live values (`getLocals`) — and write the result
42
- * to the target if it differs from the previous output (always, the first
42
+ * to the target if it differs from the previous output or the target's value (always, the first
43
43
  * time). Reading live values rather than the render's snapshot means the
44
44
  * first computation sees a local a preceding {set} just assigned (the scope's
45
45
  * context value only catches up on its re-render), and the first computation
@@ -53,22 +53,39 @@ function computeAndApply(
53
53
  getLocals: () => Record<string, unknown>,
54
54
  rawArgs: string,
55
55
  prevRef: { current: unknown },
56
+ lastNavigation: { current: number },
56
57
  localsUpdate: ((key: string, value: unknown) => void) | null,
57
58
  ): void {
58
59
  // Before evaluating: the expression may navigate away from this passage
59
60
  const location = currentSourceLocation();
60
61
  let newValue: unknown;
62
+ let current: unknown = prevRef.current;
63
+ let navigated = false;
64
+ const { navigationId } = useStoryStore.getState();
61
65
  try {
62
66
  // In program order, also when mutation code sets this off
63
67
  const { variables, temporary, transient } = readState();
68
+ // A navigation (or restored session) resets the temporary namespace,
69
+ // which a persistent {computed} survives: the target is then gone though
70
+ // the result is unchanged, and is written again. Between navigations a
71
+ // target the story removed or set itself stays as it is.
72
+ if (!isLocal) {
73
+ current = ownValue(isTemp ? temporary : variables, name);
74
+ navigated = navigationId !== lastNavigation.current;
75
+ }
64
76
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
65
77
  } catch (err) {
66
78
  logMacroError(`computed ${rawArgs}`, err, location);
67
79
  return;
68
80
  }
69
81
 
82
+ lastNavigation.current = navigationId;
83
+
70
84
  // Strict: a change of only the aliases inside the result is a change
71
- if (!deepEqualStrict(prevRef.current, newValue)) {
85
+ if (
86
+ !deepEqualStrict(prevRef.current, newValue) ||
87
+ (navigated && !deepEqualStrict(current, newValue))
88
+ ) {
72
89
  prevRef.current = newValue;
73
90
  if (isLocal) {
74
91
  try {
@@ -111,6 +128,8 @@ defineMacro({
111
128
  const localsUpdate = isLocal ? ctx.update : null;
112
129
 
113
130
  const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
131
+ // The navigation the target was last checked in
132
+ const lastNavigation = ctx.hooks.useRef(-1);
114
133
 
115
134
  const compute = () =>
116
135
  computeAndApply(
@@ -121,6 +140,7 @@ defineMacro({
121
140
  ctx.getValues,
122
141
  rawArgs,
123
142
  prevOutput,
143
+ lastNavigation,
124
144
  localsUpdate,
125
145
  );
126
146
 
@@ -3,9 +3,10 @@ import { LocalsValuesContext } from '../../markup/render';
3
3
  import { defineMacro } from '../../define-macro';
4
4
  import { MacroError } from './MacroError';
5
5
  import { stableKey } from '../../utils/stable-key';
6
- import { controlEditVersion } from '../../utils/control-edits';
6
+ import { controlEditVersion, editedItem } from '../../utils/control-edits';
7
7
  import type { ASTNode } from '../../markup/ast';
8
8
  import { checkVariableName } from '../../utils/namespace';
9
+ import { readState } from '../../execute-mutation';
9
10
  import { LocalsScope } from './locals-scope';
10
11
 
11
12
  /**
@@ -131,13 +132,15 @@ defineMacro({
131
132
  // macros run again for the new item (#45) -- unless the change is a
132
133
  // reader editing the item through a control: remounting would drop the
133
134
  // control's focus after the first keystroke.
134
- const edited = controlEditVersion() !== keys.edits;
135
+ const since = keys.edits;
136
+ const { variables } = readState();
135
137
  keys.edits = controlEditVersion();
136
138
  const generations = list.map((item, i) => {
137
139
  const contents = stableKey(item);
138
140
  if (
139
141
  keys.generations[i] === undefined ||
140
- (contents !== keys.contents[i] && !edited)
142
+ (contents !== keys.contents[i] &&
143
+ !editedItem(since, variables, list, item, i))
141
144
  ) {
142
145
  keys.generations[i] = keys.next++;
143
146
  }
@@ -49,7 +49,10 @@ defineMacro({
49
49
  const containerRef = useRef<HTMLSpanElement>(null);
50
50
  const innerRef = useRef<HTMLSpanElement>(null);
51
51
  const [clip, setClip] = useState<string | undefined>();
52
- const [totalChars, setTotalChars] = useState(0);
52
+ // Null until the content has been measured: zero characters is then a
53
+ // result (nothing to animate), not a pending measurement.
54
+ const [measured, setMeasured] = useState<number | null>(null);
55
+ const totalChars = measured ?? 0;
53
56
  const [visibleChars, setVisibleChars] = useState(0);
54
57
  const visibleCharsRef = useRef(0);
55
58
  visibleCharsRef.current = visibleChars;
@@ -59,7 +62,7 @@ defineMacro({
59
62
  useEffect(() => {
60
63
  if (containerRef.current) {
61
64
  const text = containerRef.current.textContent || '';
62
- setTotalChars(text.length);
65
+ setMeasured(text.length);
63
66
  }
64
67
  });
65
68
 
@@ -81,7 +84,8 @@ defineMacro({
81
84
  return () => clearInterval(timer);
82
85
  }, [totalChars, speed]);
83
86
 
84
- const done = visibleChars >= totalChars && totalChars > 0;
87
+ // Empty text can still become reactive text later, which restarts typing
88
+ const done = measured !== null && visibleChars >= totalChars;
85
89
 
86
90
  // Clip to the characters revealed so far, as laid out: remeasured when
87
91
  // the count changes and when the width the text wraps in does.
@@ -117,7 +121,7 @@ defineMacro({
117
121
  class="macro-type-inner"
118
122
  style={{
119
123
  display: 'inline',
120
- visibility: totalChars === 0 ? 'hidden' : 'visible',
124
+ visibility: measured === null ? 'hidden' : 'visible',
121
125
  clipPath: clip,
122
126
  }}
123
127
  >
@@ -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
@@ -229,7 +229,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
229
229
  // In program order, also when mutation code performs the input
230
230
  ctx.getValue = () => getByPath(readState().variables, segments);
231
231
  ctx.setValue = (value: unknown) => {
232
- noteControlEdit();
232
+ noteControlEdit(segments);
233
233
  // The commit mutation code uses, which keeps the references among
234
234
  // variables: a write to a draft copies only the path written, and
235
235
  // other variables sharing the object would keep the old one.
@@ -138,6 +138,42 @@ export function frozenCopy<T>(value: T): T {
138
138
  return freeze(cloneValue(value), true);
139
139
  }
140
140
 
141
+ /** Whether `value` and everything it holds is frozen. */
142
+ function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
143
+ if (typeof value !== 'object' || value === null || seen.has(value)) {
144
+ return true;
145
+ }
146
+ // Leaves a freeze cannot lock (typed array elements) and that a copy would
147
+ // detach from what they share (a buffer): handed out as they are
148
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
149
+ if (!Object.isFrozen(value)) return false;
150
+ seen.add(value);
151
+ // What a Map or Set holds is not in its properties (#388)
152
+ if (value instanceof Map) {
153
+ for (const [key, entry] of value) {
154
+ if (!deeplyFrozen(key, seen) || !deeplyFrozen(entry, seen)) return false;
155
+ }
156
+ } else if (value instanceof Set) {
157
+ for (const member of value) {
158
+ if (!deeplyFrozen(member, seen)) return false;
159
+ }
160
+ }
161
+ return Reflect.ownKeys(value).every((key) => {
162
+ const desc = Object.getOwnPropertyDescriptor(value, key);
163
+ return !desc || !('value' in desc) || deeplyFrozen(desc.value, seen);
164
+ });
165
+ }
166
+
167
+ /**
168
+ * A value read from the store, made safe to hand out: the store holds plain
169
+ * values frozen, but not registered class instances, whose methods could
170
+ * change the recorded history through the live object. Such a value is
171
+ * returned as a frozen copy, like a read inside mutation code.
172
+ */
173
+ export function readOnlyValue<T>(value: T): T {
174
+ return deeplyFrozen(value) ? value : frozenCopy(value);
175
+ }
176
+
141
177
  /**
142
178
  * Apply a write that reached the store while mutations execute to their
143
179
  * `work` and `base` copies (see MutationScope), so the code sees it and
@@ -9,14 +9,31 @@ const FOCUSABLE = [
9
9
  'select:not([disabled])',
10
10
  'textarea:not([disabled])',
11
11
  'iframe',
12
- '[contenteditable="true"]',
12
+ 'summary',
13
+ '[contenteditable]',
13
14
  '[tabindex]',
14
15
  ].join(',');
15
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
+
16
32
  /**
17
33
  * Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
18
- * (inherited, so the computed value covers ancestors) and not inside a
19
- * `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>`.
20
37
  */
21
38
  function isRendered(el: HTMLElement): boolean {
22
39
  const { visibility } = getComputedStyle(el);
@@ -24,7 +41,21 @@ function isRendered(el: HTMLElement): boolean {
24
41
  for (let e: HTMLElement | null = el; e; e = e.parentElement) {
25
42
  if (getComputedStyle(e).display === 'none') return false;
26
43
  }
27
- 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
+ );
28
59
  }
29
60
 
30
61
  /** The `tabindex` of `el` as an integer, or null when absent or invalid. */
@@ -35,19 +66,46 @@ function tabIndexAttr(el: HTMLElement): number | null {
35
66
  }
36
67
 
37
68
  /**
38
- * Whether the tab key can reach `el`: a negative tabindex only allows
39
- * scripted focus, and of a radio group the browser tabs to the checked
40
- * 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.
41
73
  */
42
74
  function isTabStop(el: HTMLElement): boolean {
43
75
  const tabIndex = tabIndexAttr(el);
44
76
  if (tabIndex !== null && tabIndex < 0) return false;
77
+ // Only an editing host takes focus, not `contenteditable="false"` or
78
+ // content nested in a host
79
+ if (
80
+ el.hasAttribute('contenteditable') &&
81
+ !el.matches('a[href], button, input, select, textarea, iframe, summary') &&
82
+ (!el.isContentEditable || el.parentElement?.isContentEditable)
83
+ ) {
84
+ return false;
85
+ }
86
+ // Only a details element's first summary child is its toggle
87
+ if (el.localName === 'summary') {
88
+ const details = el.parentElement;
89
+ if (
90
+ details?.localName !== 'details' ||
91
+ Array.from(details.children).find((c) => c.localName === 'summary') !== el
92
+ ) {
93
+ return tabIndex !== null;
94
+ }
95
+ }
45
96
  if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
46
97
  const group = Array.from(
47
98
  (
48
99
  el.form ?? (el.getRootNode() as ParentNode)
49
100
  ).querySelectorAll<HTMLInputElement>('input[type="radio"]'),
50
- ).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
+ );
51
109
  const stop = group.find((r) => r.checked) ?? group[0];
52
110
  return el === stop;
53
111
  }
@@ -61,15 +119,7 @@ function isTabStop(el: HTMLElement): boolean {
61
119
  function focusables(root: HTMLElement): HTMLElement[] {
62
120
  const stops = Array.from(
63
121
  root.querySelectorAll<HTMLElement>(FOCUSABLE),
64
- ).filter(
65
- (el) =>
66
- !el.hidden &&
67
- !el.closest('[hidden], [inert]') &&
68
- // Also covers controls disabled by a <fieldset disabled> ancestor
69
- !el.matches(':disabled') &&
70
- isRendered(el) &&
71
- isTabStop(el),
72
- );
122
+ ).filter((el) => isAvailable(el) && isTabStop(el));
73
123
  const positive = (el: HTMLElement) => Math.max(tabIndexAttr(el) ?? 0, 0);
74
124
  // Array.prototype.sort is stable: equal tabindexes keep document order
75
125
  return [