@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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +817 -272
- package/dist/pkg/story-variables.js +330 -160
- package/package.json +1 -1
- package/src/class-registry.ts +213 -24
- package/src/components/Passage.tsx +21 -13
- package/src/components/PassageDialog.tsx +12 -2
- package/src/components/macros/Radiobutton.tsx +4 -1
- package/src/components/macros/WidgetInvocation.tsx +7 -2
- package/src/execute-mutation.ts +11 -0
- package/src/hooks/use-modal-focus.ts +46 -16
- package/src/interpolation.ts +25 -5
- package/src/markup/render.tsx +28 -5
- package/src/markup/spindle.peggy +16 -0
- package/src/store.ts +12 -2
- package/src/structural.ts +136 -36
- package/src/triggers.ts +6 -2
- package/src/utils/stable-key.ts +8 -4
- package/src/utils/value-kinds.ts +56 -0
- package/src/widgets/widget-def.ts +27 -15
- package/types/index.d.ts +3 -1
package/package.json
CHANGED
package/src/class-registry.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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) &&
|
|
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 (!
|
|
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 (
|
|
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
|
|
388
|
+
throw malformedClass(name);
|
|
202
389
|
}
|
|
203
|
-
const ctor =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 (!
|
|
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
|
-
|
|
276
|
-
|
|
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 {
|
|
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
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 {
|
|
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">
|
|
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
|
|
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
|
@@ -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)
|
|
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
|
|