@cynodia/axiom-runtime 0.6.2-alpha.1 → 0.7.0-alpha.1
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/dom.d.ts +10 -0
- package/dist/group-fields.d.ts +11 -0
- package/dist/group-fields.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/memory-host.js +4 -0
- package/dist/mutation/values.d.ts +7 -0
- package/dist/mutation/values.js +9 -1
- package/dist/runtime-types.d.ts +12 -0
- package/dist/runtime-types.js +24 -1
- package/dist/runtime.js +321 -10
- package/dist/source.js +1 -0
- package/package.json +2 -2
package/dist/dom.d.ts
CHANGED
|
@@ -6,6 +6,16 @@ export interface DomEvent {
|
|
|
6
6
|
type: string;
|
|
7
7
|
preventDefault?(): void;
|
|
8
8
|
target?: unknown;
|
|
9
|
+
/**
|
|
10
|
+
* The key, for a keyboard event.
|
|
11
|
+
*
|
|
12
|
+
* Named the way every host already names it, and read for exactly two purposes: dismissing
|
|
13
|
+
* a dialog on `Escape` and containing focus on `Tab`. It is not a general keyboard channel
|
|
14
|
+
* — an application cannot bind a key, because a key binding would be behaviour expressed
|
|
15
|
+
* outside the graph.
|
|
16
|
+
*/
|
|
17
|
+
key?: string;
|
|
18
|
+
shiftKey?: boolean;
|
|
9
19
|
}
|
|
10
20
|
export type DomListener = (event: DomEvent) => void;
|
|
11
21
|
export interface DomElement {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two well-known field ids a `group` expression's results carry.
|
|
3
|
+
*
|
|
4
|
+
* Declared here rather than imported from `@cynodia/axiom-core` for the same reason `NodeId`
|
|
5
|
+
* is declared locally in `runtime-types.ts`: a value imported from core would be stripped
|
|
6
|
+
* out of the browser bundle and become `undefined` in a page. `packages/runtime/test/host.test.ts`
|
|
7
|
+
* fails if these drift from `GROUP_KEY_FIELD` and `GROUP_ITEMS_FIELD` in core.
|
|
8
|
+
*/
|
|
9
|
+
export declare const GROUP_KEY_FIELD = "field_group_key";
|
|
10
|
+
export declare const GROUP_ITEMS_FIELD = "field_group_items";
|
|
11
|
+
//# sourceMappingURL=group-fields.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two well-known field ids a `group` expression's results carry.
|
|
3
|
+
*
|
|
4
|
+
* Declared here rather than imported from `@cynodia/axiom-core` for the same reason `NodeId`
|
|
5
|
+
* is declared locally in `runtime-types.ts`: a value imported from core would be stripped
|
|
6
|
+
* out of the browser bundle and become `undefined` in a page. `packages/runtime/test/host.test.ts`
|
|
7
|
+
* fails if these drift from `GROUP_KEY_FIELD` and `GROUP_ITEMS_FIELD` in core.
|
|
8
|
+
*/
|
|
9
|
+
export const GROUP_KEY_FIELD = 'field_group_key';
|
|
10
|
+
export const GROUP_ITEMS_FIELD = 'field_group_items';
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/memory-host.js
CHANGED
|
@@ -35,6 +35,10 @@ export class MemoryElement {
|
|
|
35
35
|
}
|
|
36
36
|
focus() {
|
|
37
37
|
this.focused = true;
|
|
38
|
+
// A real host fires a focus event, and the renderer listens for one to track where focus
|
|
39
|
+
// is. A double that sets a flag without dispatching would let focus-dependent behaviour
|
|
40
|
+
// pass here and fail in a browser.
|
|
41
|
+
this.dispatch('focus');
|
|
38
42
|
}
|
|
39
43
|
dispatch(type, event = {}) {
|
|
40
44
|
const payload = { type, target: this, preventDefault: () => undefined, ...event };
|
|
@@ -42,5 +42,12 @@ export declare function toText(value: unknown): string;
|
|
|
42
42
|
*/
|
|
43
43
|
export declare function compareText(left: string, right: string): number;
|
|
44
44
|
export declare function compareValues(left: unknown, right: unknown): number;
|
|
45
|
+
/**
|
|
46
|
+
* A stable serialization, so record comparison does not depend on key order.
|
|
47
|
+
*
|
|
48
|
+
* Also the identity of a group key: two keys are the same key when they serialize the same
|
|
49
|
+
* way, which is what lets a key be a record and not only a primitive.
|
|
50
|
+
*/
|
|
51
|
+
export declare function canonicalKey(value: unknown): string;
|
|
45
52
|
export declare function valuesEqual(left: unknown, right: unknown): boolean;
|
|
46
53
|
//# sourceMappingURL=values.d.ts.map
|
package/dist/mutation/values.js
CHANGED
|
@@ -104,7 +104,15 @@ export function compareValues(left, right) {
|
|
|
104
104
|
}
|
|
105
105
|
return compareText(toText(left), toText(right));
|
|
106
106
|
}
|
|
107
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* A stable serialization, so record comparison does not depend on key order.
|
|
109
|
+
*
|
|
110
|
+
* Also the identity of a group key: two keys are the same key when they serialize the same
|
|
111
|
+
* way, which is what lets a key be a record and not only a primitive.
|
|
112
|
+
*/
|
|
113
|
+
export function canonicalKey(value) {
|
|
114
|
+
return canonical(value);
|
|
115
|
+
}
|
|
108
116
|
function canonical(value) {
|
|
109
117
|
if (value === null || typeof value !== 'object') {
|
|
110
118
|
return JSON.stringify(value) ?? 'null';
|
package/dist/runtime-types.d.ts
CHANGED
|
@@ -54,4 +54,16 @@ export interface RemoteGateway {
|
|
|
54
54
|
states: Record<NodeId, unknown>;
|
|
55
55
|
}>;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The UI node kinds the browser renderer draws.
|
|
59
|
+
*
|
|
60
|
+
* It lives beside the renderer rather than in core because the renderer is what has to be
|
|
61
|
+
* true to it: a kind listed here without a `case` in `renderNode` is a lie that validation
|
|
62
|
+
* would then repeat. `packages/runtime/test/host.test.ts` renders one of every kind on this
|
|
63
|
+
* list and fails if any reports `UNSUPPORTED_UI_NODE`.
|
|
64
|
+
*/
|
|
65
|
+
export declare const BROWSER_RENDERER_CAPABILITIES: {
|
|
66
|
+
readonly target: "browser";
|
|
67
|
+
readonly supportedUiKinds: readonly ["view", "container", "text", "repeat", "field-display", "form", "input", "button", "conditional", "diagnostic", "dialog"];
|
|
68
|
+
};
|
|
57
69
|
//# sourceMappingURL=runtime-types.d.ts.map
|
package/dist/runtime-types.js
CHANGED
|
@@ -1 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The UI node kinds the browser renderer draws.
|
|
3
|
+
*
|
|
4
|
+
* It lives beside the renderer rather than in core because the renderer is what has to be
|
|
5
|
+
* true to it: a kind listed here without a `case` in `renderNode` is a lie that validation
|
|
6
|
+
* would then repeat. `packages/runtime/test/host.test.ts` renders one of every kind on this
|
|
7
|
+
* list and fails if any reports `UNSUPPORTED_UI_NODE`.
|
|
8
|
+
*/
|
|
9
|
+
export const BROWSER_RENDERER_CAPABILITIES = {
|
|
10
|
+
target: 'browser',
|
|
11
|
+
supportedUiKinds: [
|
|
12
|
+
'view',
|
|
13
|
+
'container',
|
|
14
|
+
'text',
|
|
15
|
+
'repeat',
|
|
16
|
+
'field-display',
|
|
17
|
+
'form',
|
|
18
|
+
'input',
|
|
19
|
+
'button',
|
|
20
|
+
'conditional',
|
|
21
|
+
'diagnostic',
|
|
22
|
+
'dialog',
|
|
23
|
+
],
|
|
24
|
+
};
|
package/dist/runtime.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { GROUP_ITEMS_FIELD, GROUP_KEY_FIELD } from './group-fields.js';
|
|
1
2
|
import { formatValue } from './format.js';
|
|
2
3
|
import { ariaRoleFor, headingTag, landmarkTag, presentationClasses, } from './presentation-classes.js';
|
|
3
4
|
import { createMutationEngine } from './mutation/mutation-engine.js';
|
|
4
5
|
import { LocationResolutionError, resolveLocation } from './mutation/resolve-location.js';
|
|
5
6
|
import { createStateStore } from './mutation/store.js';
|
|
6
7
|
import { createTransactionManager } from './mutation/transaction.js';
|
|
7
|
-
import { ExpressionEvaluationError, cloneValue, compareValues, deepFreeze, isEmptyValue, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
|
|
8
|
+
import { ExpressionEvaluationError, canonicalKey, cloneValue, compareValues, deepFreeze, isEmptyValue, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
|
|
8
9
|
/**
|
|
9
10
|
* The diagnostics a runtime can report. Agents match on the code rather than parsing the
|
|
10
11
|
* message, so this vocabulary is part of the public contract.
|
|
@@ -133,6 +134,52 @@ export function createAxiomRuntime(options) {
|
|
|
133
134
|
* convention about where inputs are bound.
|
|
134
135
|
*/
|
|
135
136
|
let applyingAuthoritative = false;
|
|
137
|
+
/** Dialogs currently rendered open, so focus moves in once rather than on every render. */
|
|
138
|
+
const openDialogs = new Set();
|
|
139
|
+
/** The instance that held focus when each open dialog took it. */
|
|
140
|
+
const dialogReturnTargets = new Map();
|
|
141
|
+
/** An explicit override of where focus returns, by semantic node. */
|
|
142
|
+
const declaredReturnFocus = new Map();
|
|
143
|
+
/** The instance holding focus as the current render began. */
|
|
144
|
+
let openedFromInstance = null;
|
|
145
|
+
/**
|
|
146
|
+
* Where focus goes after this render, because a dialog just closed.
|
|
147
|
+
*
|
|
148
|
+
* A **render instance**, not a node id. `returnFocusId` names a semantic node, and a node
|
|
149
|
+
* inside a `repeat` is many rendered elements — so returning focus by node id sends a
|
|
150
|
+
* keyboard user to the last row rendered rather than the row they opened the dialog from.
|
|
151
|
+
* What is remembered instead is the instance that actually held focus when the dialog
|
|
152
|
+
* opened, which is both correct for repeats and correct when the trigger was not the
|
|
153
|
+
* declared `returnFocusId` at all.
|
|
154
|
+
*/
|
|
155
|
+
let pendingFocusReturn = null;
|
|
156
|
+
/**
|
|
157
|
+
* A dialog that opened during this render, and the control focus belongs on.
|
|
158
|
+
*
|
|
159
|
+
* Focus cannot be moved while the tree is being built: an element that is not in the
|
|
160
|
+
* document yet is not focusable, so `focus()` on it does nothing in a browser. The
|
|
161
|
+
* in-memory host is more forgiving, which is exactly why this was wrong for a release and
|
|
162
|
+
* only a real browser caught it. Entry focus is therefore deferred to after the render,
|
|
163
|
+
* beside the focus restoration that already happens there.
|
|
164
|
+
*/
|
|
165
|
+
let pendingDialogFocus = null;
|
|
166
|
+
/**
|
|
167
|
+
* Which render the elements on screen belong to.
|
|
168
|
+
*
|
|
169
|
+
* Rendering is a full replace, so removing a focused control makes the browser fire `blur`
|
|
170
|
+
* and — if its value changed — `change`, **on the element that was just detached**. Handling
|
|
171
|
+
* that as a new intent re-enters the render from inside itself and applies the same
|
|
172
|
+
* mutation twice. A control therefore ignores events that arrive after the render which
|
|
173
|
+
* created it. Nothing in the in-memory host fires those events, which is why only a real
|
|
174
|
+
* browser found this.
|
|
175
|
+
*/
|
|
176
|
+
let renderGeneration = 0;
|
|
177
|
+
/** Every focusable control of the current render, by instance key. */
|
|
178
|
+
const focusableControls = new Map();
|
|
179
|
+
/** The instance that last held focus, so a dialog knows where to send it back. */
|
|
180
|
+
let lastFocusedControl = null;
|
|
181
|
+
/** Instance keys of the elements rendered for a semantic node, for `returnFocusId`. */
|
|
182
|
+
const instancesOfNode = new Map();
|
|
136
183
|
let remoteRequests = 0;
|
|
137
184
|
/** Generated once, and only when this runtime can actually talk to an authority. */
|
|
138
185
|
const sessionId = remote ? `s${(runtimeSessions += 1)}-${host.uuid()}` : '';
|
|
@@ -210,6 +257,7 @@ export function createAxiomRuntime(options) {
|
|
|
210
257
|
return safe === '' ? `i${index}` : safe;
|
|
211
258
|
}
|
|
212
259
|
const statesById = new Map(ir.states.map((state) => [state.id, state]));
|
|
260
|
+
const expressionDefsById = new Map(Object.entries(ir.expressionDefs ?? {}));
|
|
213
261
|
const entitiesById = new Map(ir.entities.map((entity) => [entity.id, entity]));
|
|
214
262
|
const parameterTypes = new Map();
|
|
215
263
|
for (const action of Object.values(ir.actions)) {
|
|
@@ -531,6 +579,45 @@ export function createAxiomRuntime(options) {
|
|
|
531
579
|
const source = requireCollection(evaluate(expression.source, scope), 'flatten');
|
|
532
580
|
return source.flatMap((member) => requireCollection(member, 'flatten'));
|
|
533
581
|
}
|
|
582
|
+
case 'group': {
|
|
583
|
+
const source = requireCollection(evaluate(expression.source, scope), 'group');
|
|
584
|
+
// Groups in first-appearance order, members in source order, keys compared
|
|
585
|
+
// structurally. All three are contract, not implementation detail.
|
|
586
|
+
const groups = new Map();
|
|
587
|
+
for (const item of source) {
|
|
588
|
+
const key = evaluate(expression.by, childScope(scope, expression.scopeId, item));
|
|
589
|
+
const identity = canonicalKey(key);
|
|
590
|
+
const existing = groups.get(identity);
|
|
591
|
+
if (existing) {
|
|
592
|
+
existing.items.push(item);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
groups.set(identity, { key, items: [item] });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return [...groups.values()].map((entry) => ({
|
|
599
|
+
[GROUP_KEY_FIELD]: entry.key,
|
|
600
|
+
[GROUP_ITEMS_FIELD]: entry.items,
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
case 'expression-ref': {
|
|
604
|
+
const definition = expressionDefsById.get(expression.expressionId);
|
|
605
|
+
if (!definition) {
|
|
606
|
+
throw new ExpressionEvaluationError(`Expression definition ${expression.expressionId} is not part of this application`, { expressionId: expression.expressionId });
|
|
607
|
+
}
|
|
608
|
+
// Arguments are evaluated here; the body is evaluated in a scope of its own, with
|
|
609
|
+
// no parent. That isolation is the definition's contract: it cannot see the
|
|
610
|
+
// caller's iteration scopes, so it means the same thing everywhere it is used.
|
|
611
|
+
const bound = new Map();
|
|
612
|
+
for (const parameter of definition.parameters ?? []) {
|
|
613
|
+
const argument = expression.arguments?.[String(parameter.id)];
|
|
614
|
+
if (argument === undefined) {
|
|
615
|
+
throw new ExpressionEvaluationError(`Expression ${definition.id} was used without supplying ${parameter.id}`, { expressionId: definition.id, parameterId: parameter.id });
|
|
616
|
+
}
|
|
617
|
+
bound.set(String(parameter.id), evaluate(argument, scope));
|
|
618
|
+
}
|
|
619
|
+
return evaluate(definition.expression, { values: bound });
|
|
620
|
+
}
|
|
534
621
|
case 'conditional':
|
|
535
622
|
return toBoolean(evaluate(expression.condition, scope))
|
|
536
623
|
? evaluate(expression.whenTrue, scope)
|
|
@@ -1309,7 +1396,37 @@ export function createAxiomRuntime(options) {
|
|
|
1309
1396
|
}
|
|
1310
1397
|
return created;
|
|
1311
1398
|
}
|
|
1312
|
-
|
|
1399
|
+
const dialogFocusStack = [];
|
|
1400
|
+
const openDialogFocus = new Map();
|
|
1401
|
+
/**
|
|
1402
|
+
* Registers a control the keyboard can reach.
|
|
1403
|
+
*
|
|
1404
|
+
* Every focusable control, not only the ones inside a dialog: a dialog has to know where
|
|
1405
|
+
* focus was before it opened, and that is somewhere else in the document by definition.
|
|
1406
|
+
* Every kind that can hold focus registers here — a dialog containing a text field must
|
|
1407
|
+
* trap it, and a trap that only knew about buttons would let focus walk straight out.
|
|
1408
|
+
*/
|
|
1409
|
+
function registerFocusable(target, nodeIdentity, instanceKeyValue) {
|
|
1410
|
+
focusableControls.set(instanceKeyValue, target);
|
|
1411
|
+
const forNode = instancesOfNode.get(nodeIdentity) ?? [];
|
|
1412
|
+
forNode.push(instanceKeyValue);
|
|
1413
|
+
instancesOfNode.set(nodeIdentity, forNode);
|
|
1414
|
+
const current = dialogFocusStack[dialogFocusStack.length - 1];
|
|
1415
|
+
const index = current ? current.focusable.length : -1;
|
|
1416
|
+
if (current) {
|
|
1417
|
+
current.focusable.push(target);
|
|
1418
|
+
const dialogNode = ir.uiNodes[current.dialogId];
|
|
1419
|
+
if (dialogNode?.initialFocusId === nodeIdentity) {
|
|
1420
|
+
current.initial = target;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
target.addEventListener('focus', () => {
|
|
1424
|
+
lastFocusedControl = instanceKeyValue;
|
|
1425
|
+
if (current) {
|
|
1426
|
+
current.focusedIndex = index;
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1313
1430
|
function nodeClasses(node, ...base) {
|
|
1314
1431
|
return presentationClasses(presentationOf(node.id), ...base);
|
|
1315
1432
|
}
|
|
@@ -1807,14 +1924,29 @@ export function createAxiomRuntime(options) {
|
|
|
1807
1924
|
renderApplication();
|
|
1808
1925
|
};
|
|
1809
1926
|
// A radio group's listeners live on its radios; everything else is one control.
|
|
1927
|
+
const generation = renderGeneration;
|
|
1928
|
+
const applyIfCurrent = (event) => {
|
|
1929
|
+
if (generation !== renderGeneration) {
|
|
1930
|
+
// This element has already been replaced; the event is the browser tidying up
|
|
1931
|
+
// after it, not a person changing a value.
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
apply(event);
|
|
1935
|
+
};
|
|
1810
1936
|
for (const target of grouped ? radios : [control]) {
|
|
1811
|
-
target.addEventListener('input',
|
|
1812
|
-
target.addEventListener('change',
|
|
1937
|
+
target.addEventListener('input', applyIfCurrent);
|
|
1938
|
+
target.addEventListener('change', applyIfCurrent);
|
|
1813
1939
|
target.addEventListener('focus', () => {
|
|
1814
1940
|
focusedInstance = instance;
|
|
1815
1941
|
});
|
|
1816
1942
|
}
|
|
1817
1943
|
inputElements.set(instance, control);
|
|
1944
|
+
// A control is focusable whatever kind it is. A dialog containing a text field must
|
|
1945
|
+
// trap it, and a trap that knew only about buttons would let focus walk out of the
|
|
1946
|
+
// first input it met.
|
|
1947
|
+
for (const [offset, focusable] of (grouped ? radios : [control]).entries()) {
|
|
1948
|
+
registerFocusable(focusable, node.id, grouped ? `${instance}#${offset}` : instance);
|
|
1949
|
+
}
|
|
1818
1950
|
wrapper.appendChild(control);
|
|
1819
1951
|
if (presentation?.description) {
|
|
1820
1952
|
const help = element('span', 'axiom-input-description axiom-text-caption');
|
|
@@ -1898,14 +2030,110 @@ export function createAxiomRuntime(options) {
|
|
|
1898
2030
|
// The form invokes exactly this, so the button's arguments are not lost.
|
|
1899
2031
|
button.setAttribute('type', 'submit');
|
|
1900
2032
|
submitInvokers.set(instanceKey(submits.formId, path), invoke);
|
|
2033
|
+
registerFocusable(button, node.id, instance);
|
|
1901
2034
|
return button;
|
|
1902
2035
|
}
|
|
1903
2036
|
button.addEventListener('click', (event) => {
|
|
1904
2037
|
event.preventDefault?.();
|
|
1905
2038
|
invoke();
|
|
1906
2039
|
});
|
|
2040
|
+
registerFocusable(button, node.id, instance);
|
|
1907
2041
|
return button;
|
|
1908
2042
|
}
|
|
2043
|
+
case 'dialog': {
|
|
2044
|
+
// Visibility first: a closed dialog is absent, not hidden. Nothing inside it is
|
|
2045
|
+
// rendered, so nothing inside it is reachable by keyboard or by assistive technology.
|
|
2046
|
+
if (!toBoolean(evaluate(node.openWhen, scope))) {
|
|
2047
|
+
if (openDialogs.delete(node.id)) {
|
|
2048
|
+
// It was open and is now closed: focus goes back to whatever opened it, rather
|
|
2049
|
+
// than to the top of the document. A declared override wins, resolved after the
|
|
2050
|
+
// render, when its instances are known.
|
|
2051
|
+
const declared = declaredReturnFocus.get(node.id);
|
|
2052
|
+
pendingFocusReturn = declared
|
|
2053
|
+
? `declared:${String(declared)}`
|
|
2054
|
+
: (dialogReturnTargets.get(node.id) ?? null);
|
|
2055
|
+
dialogReturnTargets.delete(node.id);
|
|
2056
|
+
}
|
|
2057
|
+
openDialogFocus.delete(node.id);
|
|
2058
|
+
return null;
|
|
2059
|
+
}
|
|
2060
|
+
const dialog = identify(element('div', nodeClasses(node, 'axiom-dialog')));
|
|
2061
|
+
dialog.setAttribute('role', 'dialog');
|
|
2062
|
+
if (node.modal !== false) {
|
|
2063
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
2064
|
+
}
|
|
2065
|
+
// The accessible name is the title element, related by id — not a copied string, so
|
|
2066
|
+
// the name and the visible heading cannot drift apart.
|
|
2067
|
+
const titleId = `axiom-dialog-title-${instance}`;
|
|
2068
|
+
const title = element('h2', 'axiom-dialog-title axiom-text-heading');
|
|
2069
|
+
title.setAttribute('id', titleId);
|
|
2070
|
+
title.textContent =
|
|
2071
|
+
typeof node.title === 'string' ? node.title : toText(evaluate(node.title, scope));
|
|
2072
|
+
dialog.setAttribute('aria-labelledby', titleId);
|
|
2073
|
+
dialog.appendChild(title);
|
|
2074
|
+
if (node.description !== undefined) {
|
|
2075
|
+
const describedId = `axiom-dialog-description-${instance}`;
|
|
2076
|
+
const description = element('p', 'axiom-dialog-description axiom-text-body');
|
|
2077
|
+
description.setAttribute('id', describedId);
|
|
2078
|
+
description.textContent =
|
|
2079
|
+
typeof node.description === 'string'
|
|
2080
|
+
? node.description
|
|
2081
|
+
: toText(evaluate(node.description, scope));
|
|
2082
|
+
dialog.setAttribute('aria-describedby', describedId);
|
|
2083
|
+
dialog.appendChild(description);
|
|
2084
|
+
}
|
|
2085
|
+
const focus = { dialogId: node.id, focusable: [], focusedIndex: -1 };
|
|
2086
|
+
dialogFocusStack.push(focus);
|
|
2087
|
+
renderChildren(node.children, scope, dialog, path);
|
|
2088
|
+
dialogFocusStack.pop();
|
|
2089
|
+
openDialogFocus.set(node.id, focus);
|
|
2090
|
+
// Escape dismisses by invoking the declared close action. What dismissal *means* is
|
|
2091
|
+
// that action's business: the runtime never infers that closing cancels anything.
|
|
2092
|
+
dialog.addEventListener('keydown', (event) => {
|
|
2093
|
+
if (event.key === 'Escape') {
|
|
2094
|
+
event.preventDefault?.();
|
|
2095
|
+
runAction(node.closeActionId, {});
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if (event.key === 'Tab' && node.modal !== false) {
|
|
2099
|
+
// Containment: a modal that lets focus walk out of it is not modal. Only the
|
|
2100
|
+
// wrap is handled here; ordinary Tab movement is the host's.
|
|
2101
|
+
const { focusable, focusedIndex } = focus;
|
|
2102
|
+
if (focusable.length === 0) {
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
const atStart = focusedIndex <= 0;
|
|
2106
|
+
const atEnd = focusedIndex === focusable.length - 1;
|
|
2107
|
+
if (event.shiftKey ? atStart : atEnd) {
|
|
2108
|
+
event.preventDefault?.();
|
|
2109
|
+
const wrapTo = event.shiftKey ? focusable[focusable.length - 1] : focusable[0];
|
|
2110
|
+
wrapTo.focus?.();
|
|
2111
|
+
focus.focusedIndex = event.shiftKey ? focusable.length - 1 : 0;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
});
|
|
2115
|
+
// Focus moves in when it opens, and back out to whatever opened it when it closes.
|
|
2116
|
+
// Focus moves in the first time it is rendered open, and not on every re-render:
|
|
2117
|
+
// a full re-render must not steal focus back from wherever the person moved it.
|
|
2118
|
+
if (!openDialogs.has(node.id)) {
|
|
2119
|
+
openDialogs.add(node.id);
|
|
2120
|
+
// Where focus was, before this dialog took it. Captured by instance, so a trigger
|
|
2121
|
+
// inside a repeat sends focus back to the row it was actually pressed in.
|
|
2122
|
+
dialogReturnTargets.set(node.id, openedFromInstance ?? null);
|
|
2123
|
+
const initial = focus.initial ?? focus.focusable[0];
|
|
2124
|
+
// Applied once the tree is attached — see `pendingDialogFocus`.
|
|
2125
|
+
pendingDialogFocus = initial ?? null;
|
|
2126
|
+
focus.focusedIndex = initial ? focus.focusable.indexOf(initial) : -1;
|
|
2127
|
+
}
|
|
2128
|
+
// A declared `returnFocusId` is an override for the case where focus should not go
|
|
2129
|
+
// back where it came from. It can only name a node, so it resolves to that node's
|
|
2130
|
+
// sole rendered instance; naming one inside a repeat is ambiguous and validation
|
|
2131
|
+
// rejects it.
|
|
2132
|
+
if (node.returnFocusId) {
|
|
2133
|
+
declaredReturnFocus.set(node.id, node.returnFocusId);
|
|
2134
|
+
}
|
|
2135
|
+
return dialog;
|
|
2136
|
+
}
|
|
1909
2137
|
case 'diagnostic': {
|
|
1910
2138
|
const region = identify(element('div', nodeClasses(node, 'axiom-diagnostic')));
|
|
1911
2139
|
region.setAttribute('id', `axiom-diagnostic-${instance}`);
|
|
@@ -1949,8 +2177,17 @@ export function createAxiomRuntime(options) {
|
|
|
1949
2177
|
}
|
|
1950
2178
|
}
|
|
1951
2179
|
function renderApplication() {
|
|
2180
|
+
renderGeneration += 1;
|
|
1952
2181
|
inputElements.clear();
|
|
1953
2182
|
submitInvokers.clear();
|
|
2183
|
+
// Which instance held focus as this render began. A dialog opening during it captures
|
|
2184
|
+
// this, so closing returns focus to the control that was actually being used — the right
|
|
2185
|
+
// row of a repeat, not the last one rendered.
|
|
2186
|
+
openedFromInstance = lastFocusedControl;
|
|
2187
|
+
// The elements are about to be replaced; the instance keys they were registered under
|
|
2188
|
+
// are stable, so only the element references are stale.
|
|
2189
|
+
focusableControls.clear();
|
|
2190
|
+
instancesOfNode.clear();
|
|
1954
2191
|
const scope = rootScope();
|
|
1955
2192
|
if (!activeRoute) {
|
|
1956
2193
|
const missing = element('div', 'axiom-no-route');
|
|
@@ -1962,7 +2199,47 @@ export function createAxiomRuntime(options) {
|
|
|
1962
2199
|
rootElement.replaceChildren(...(view ? [view] : []));
|
|
1963
2200
|
restoreFocus();
|
|
1964
2201
|
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Where focus goes when the control that opened a dialog no longer exists.
|
|
2204
|
+
*
|
|
2205
|
+
* A destructive confirmation usually removes the very row its trigger was in, so the exact
|
|
2206
|
+
* render instance is gone by the time the dialog closes. Dropping focus to the top of the
|
|
2207
|
+
* document would lose a keyboard user's place entirely; the nearest equivalent is another
|
|
2208
|
+
* instance of the same node — the next row's control. Absent that, nothing: an invented
|
|
2209
|
+
* target would be worse than none.
|
|
2210
|
+
*/
|
|
2211
|
+
function survivingSibling(instanceKeyValue) {
|
|
2212
|
+
const nodeIdentity = instanceKeyValue.split('--')[0];
|
|
2213
|
+
const remaining = instancesOfNode.get(nodeIdentity) ?? [];
|
|
2214
|
+
for (const candidate of remaining) {
|
|
2215
|
+
const element = focusableControls.get(candidate);
|
|
2216
|
+
if (element) {
|
|
2217
|
+
return element;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return undefined;
|
|
2221
|
+
}
|
|
1965
2222
|
function restoreFocus() {
|
|
2223
|
+
// A dialog that just opened takes focus, now that its controls are in the document.
|
|
2224
|
+
if (pendingDialogFocus) {
|
|
2225
|
+
const entry = pendingDialogFocus;
|
|
2226
|
+
pendingDialogFocus = null;
|
|
2227
|
+
entry.focus?.();
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
// A dialog that just closed takes precedence: focus belongs on whatever opened it, not on
|
|
2231
|
+
// whatever happened to hold focus before the re-render.
|
|
2232
|
+
if (pendingFocusReturn) {
|
|
2233
|
+
const request = pendingFocusReturn;
|
|
2234
|
+
pendingFocusReturn = null;
|
|
2235
|
+
const target = request.startsWith('declared:')
|
|
2236
|
+
? focusableControls.get((instancesOfNode.get(request.slice('declared:'.length)) ?? [])[0] ?? '')
|
|
2237
|
+
: (focusableControls.get(request) ?? survivingSibling(request));
|
|
2238
|
+
if (target) {
|
|
2239
|
+
target.focus?.();
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
1966
2243
|
if (!focusedInstance) {
|
|
1967
2244
|
return;
|
|
1968
2245
|
}
|
|
@@ -2068,6 +2345,45 @@ export function createAxiomRuntime(options) {
|
|
|
2068
2345
|
},
|
|
2069
2346
|
};
|
|
2070
2347
|
}
|
|
2348
|
+
/**
|
|
2349
|
+
* Local storage, where the browser will actually allow it.
|
|
2350
|
+
*
|
|
2351
|
+
* `localStorage` **exists and still throws**: an opaque origin — a sandboxed iframe, a
|
|
2352
|
+
* `srcdoc` document, a `data:` URL, a private window with storage blocked — denies access on
|
|
2353
|
+
* property read, not on use. Startup must not depend on that succeeding: a page that cannot
|
|
2354
|
+
* persist should render and work, keeping the state in memory instead of failing to boot.
|
|
2355
|
+
* Every call is guarded for the same reason: a write can fail on quota long after startup.
|
|
2356
|
+
*/
|
|
2357
|
+
function browserStorage(globals) {
|
|
2358
|
+
let store;
|
|
2359
|
+
try {
|
|
2360
|
+
store = globals.localStorage;
|
|
2361
|
+
if (!store) {
|
|
2362
|
+
return undefined;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
catch {
|
|
2366
|
+
return undefined;
|
|
2367
|
+
}
|
|
2368
|
+
return {
|
|
2369
|
+
read: (key) => {
|
|
2370
|
+
try {
|
|
2371
|
+
return store.getItem(key);
|
|
2372
|
+
}
|
|
2373
|
+
catch {
|
|
2374
|
+
return null;
|
|
2375
|
+
}
|
|
2376
|
+
},
|
|
2377
|
+
write: (key, value) => {
|
|
2378
|
+
try {
|
|
2379
|
+
store.setItem(key, value);
|
|
2380
|
+
}
|
|
2381
|
+
catch {
|
|
2382
|
+
globals.console?.warn?.(`Axiom could not persist ${key}: storage is unavailable`);
|
|
2383
|
+
}
|
|
2384
|
+
},
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2071
2387
|
/** Builds a host bound to the browser globals. Used by generated pages. */
|
|
2072
2388
|
export function createBrowserHost() {
|
|
2073
2389
|
const globals = globalThis;
|
|
@@ -2081,12 +2397,7 @@ export function createBrowserHost() {
|
|
|
2081
2397
|
uuid: () => typeof globals.crypto?.randomUUID === 'function'
|
|
2082
2398
|
? globals.crypto.randomUUID()
|
|
2083
2399
|
: `id-${Date.now().toString(16)}-${Math.floor(Math.random() * 1e9).toString(16)}`,
|
|
2084
|
-
storage: globals
|
|
2085
|
-
? {
|
|
2086
|
-
read: (key) => globals.localStorage.getItem(key),
|
|
2087
|
-
write: (key, value) => globals.localStorage.setItem(key, value),
|
|
2088
|
-
}
|
|
2089
|
-
: undefined,
|
|
2400
|
+
storage: browserStorage(globals),
|
|
2090
2401
|
report: (message) => globals.console?.warn?.(message),
|
|
2091
2402
|
};
|
|
2092
2403
|
}
|
package/dist/source.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cynodia/axiom-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0-alpha.1",
|
|
4
4
|
"description": "Domain-independent runtime that executes an Axiom application graph.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AskTech AS",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@cynodia/axiom-core": "0.
|
|
34
|
+
"@cynodia/axiom-core": "0.7.0-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|