@rohal12/spindle 0.59.13 → 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.
- package/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +320 -74
- package/dist/pkg/story-variables.js +17 -11
- package/package.json +1 -1
- package/src/class-registry.ts +133 -14
- package/src/components/macros/WidgetInvocation.tsx +7 -2
- package/src/execute-mutation.ts +10 -0
- package/src/hooks/use-modal-focus.ts +46 -16
- package/src/interpolation.ts +25 -5
- package/src/markup/render.tsx +11 -4
- package/src/structural.ts +85 -21
- package/src/utils/value-kinds.ts +14 -0
- package/src/widgets/widget-def.ts +27 -15
|
@@ -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
|
-
|
|
10451
|
-
|
|
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(
|
|
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
|
}
|
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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) &&
|
|
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 (!
|
|
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 (
|
|
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
|
|
308
|
+
throw malformedClass(name);
|
|
202
309
|
}
|
|
203
|
-
const ctor =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 (!
|
|
340
|
+
if (!isPlainData(data)) {
|
|
222
341
|
throw new TypeError(`spindle: Malformed data for "${name}"`);
|
|
223
342
|
}
|
|
224
343
|
Object.setPrototypeOf(data, ctor.prototype);
|
|
@@ -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
|
|
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
|
package/src/execute-mutation.ts
CHANGED
|
@@ -148,6 +148,16 @@ function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
|
|
|
148
148
|
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
|
|
149
149
|
if (!Object.isFrozen(value)) return false;
|
|
150
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
|
+
}
|
|
151
161
|
return Reflect.ownKeys(value).every((key) => {
|
|
152
162
|
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
153
163
|
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)
|
|
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
|
|
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
|
|
40
|
-
* scripted focus, and of a
|
|
41
|
-
* member only (the first one
|
|
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(
|
|
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 [
|
package/src/interpolation.ts
CHANGED
|
@@ -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?:
|
|
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:
|
|
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
|
|
285
|
+
widgetChildren: widgetChildrenOf(node.children, scope.widgetChildren),
|
|
266
286
|
});
|
|
267
287
|
}
|
|
268
288
|
|
package/src/markup/render.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { markdownToHtml } from './markdown';
|
|
|
9
9
|
import { h } from 'preact';
|
|
10
10
|
import type { ASTNode, HtmlNode, MacroNode } from './ast';
|
|
11
11
|
import { useTextScope } from '../hooks/use-interpolate';
|
|
12
|
+
import type { WidgetChildren } from '../interpolation';
|
|
12
13
|
import { useRenderOptions } from '../hooks/use-render-options';
|
|
13
14
|
import {
|
|
14
15
|
hasInterpolation,
|
|
@@ -67,7 +68,7 @@ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
|
67
68
|
|
|
68
69
|
/** Elements whose content is literal text, not markdown. */
|
|
69
70
|
const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
|
|
70
|
-
export const WidgetChildrenContext = createContext<
|
|
71
|
+
export const WidgetChildrenContext = createContext<WidgetChildren | null>(null);
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
74
|
* Components rendered for the non-text nodes of one renderNodes() call. Each
|
|
@@ -708,10 +709,16 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
|
|
|
708
709
|
}
|
|
709
710
|
|
|
710
711
|
function ChildrenSlot() {
|
|
711
|
-
const
|
|
712
|
+
const children = useContext(WidgetChildrenContext);
|
|
712
713
|
const renderOptions = useRenderOptions();
|
|
713
|
-
if (!
|
|
714
|
-
|
|
714
|
+
if (!children) return null;
|
|
715
|
+
// The children render with the widget's locals, but a {@children} among
|
|
716
|
+
// them forwards the enclosing widget's children (#386).
|
|
717
|
+
return (
|
|
718
|
+
<WidgetChildrenContext.Provider value={children.outer}>
|
|
719
|
+
{renderNodes(children.nodes, renderOptions)}
|
|
720
|
+
</WidgetChildrenContext.Provider>
|
|
721
|
+
);
|
|
715
722
|
}
|
|
716
723
|
|
|
717
724
|
/**
|