@cynodia/axiom-runtime 0.4.1-alpha.1 → 0.5.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/README.md +4 -0
- package/dist/dom.d.ts +17 -0
- package/dist/format.d.ts +8 -0
- package/dist/format.js +122 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/memory-host.d.ts +3 -1
- package/dist/memory-host.js +5 -0
- package/dist/presentation-classes.d.ts +18 -0
- package/dist/presentation-classes.js +147 -0
- package/dist/runtime.js +250 -51
- package/dist/source.js +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,6 +9,10 @@ The domain-independent runtime: the state store, expression evaluation, the muta
|
|
|
9
9
|
engine, constraint checking, the semantic UI renderer and routing. It takes its whole
|
|
10
10
|
environment through a `HostEnvironment`, so it runs in a browser or headlessly.
|
|
11
11
|
|
|
12
|
+
The renderer reads presentation that the compiler has already resolved and turns it into
|
|
13
|
+
semantic class names, landmark and heading elements, and formatted values. It writes no
|
|
14
|
+
styles and computes no lengths — what those classes mean is the theme's business.
|
|
15
|
+
|
|
12
16
|
## Installation
|
|
13
17
|
|
|
14
18
|
```bash
|
package/dist/dom.d.ts
CHANGED
|
@@ -27,6 +27,21 @@ export interface StorageAdapter {
|
|
|
27
27
|
read(key: string): string | null;
|
|
28
28
|
write(key: string, value: string): void;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* A confirmation, described rather than drawn. A host may present it however its platform
|
|
32
|
+
* presents such things; nothing here is browser-specific, and the graph never constructs
|
|
33
|
+
* dialog markup.
|
|
34
|
+
*/
|
|
35
|
+
export interface ConfirmationRequest {
|
|
36
|
+
actionId: string;
|
|
37
|
+
title: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
confirmLabel: string;
|
|
40
|
+
cancelLabel: string;
|
|
41
|
+
severity: 'informational' | 'warning' | 'destructive';
|
|
42
|
+
/** A single line for hosts that can only ask a plain question. */
|
|
43
|
+
message: string;
|
|
44
|
+
}
|
|
30
45
|
/** Everything the runtime needs from its environment, so nothing is read from globals. */
|
|
31
46
|
export interface HostEnvironment {
|
|
32
47
|
document: DomDocument;
|
|
@@ -34,6 +49,8 @@ export interface HostEnvironment {
|
|
|
34
49
|
pushPath(path: string): void;
|
|
35
50
|
onPathChange(listener: () => void): void;
|
|
36
51
|
confirm(message: string): boolean;
|
|
52
|
+
/** Preferred over `confirm` when the host can present a structured confirmation. */
|
|
53
|
+
confirmRequest?(request: ConfirmationRequest): boolean;
|
|
37
54
|
now(): string;
|
|
38
55
|
uuid(): string;
|
|
39
56
|
storage?: StorageAdapter;
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ValueFormat } from '@cynodia/axiom-core';
|
|
2
|
+
/**
|
|
3
|
+
* Renders a value for display. A value the format cannot describe — a date field holding
|
|
4
|
+
* something that is not a date — falls back to its plain text rather than inventing a
|
|
5
|
+
* plausible-looking result.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatValue(value: unknown, format: ValueFormat | undefined, locale?: string): string;
|
|
8
|
+
//# sourceMappingURL=format.d.ts.map
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { isPresent, toText } from './mutation/values.js';
|
|
2
|
+
/**
|
|
3
|
+
* Semantic value formatting. Formatting is presentation: the stored value never changes,
|
|
4
|
+
* and the graph describes what it wants with a structured `ValueFormat` rather than a
|
|
5
|
+
* function, so the description survives serialization and can be reasoned about.
|
|
6
|
+
*/
|
|
7
|
+
const DATE_STYLES = {
|
|
8
|
+
short: 'short',
|
|
9
|
+
medium: 'medium',
|
|
10
|
+
long: 'long',
|
|
11
|
+
};
|
|
12
|
+
function numberFormat(locale, options) {
|
|
13
|
+
const intl = globalThis.Intl;
|
|
14
|
+
if (!intl?.NumberFormat) {
|
|
15
|
+
return (value) => String(value);
|
|
16
|
+
}
|
|
17
|
+
const formatter = new intl.NumberFormat(locale, options);
|
|
18
|
+
return (value) => formatter.format(value);
|
|
19
|
+
}
|
|
20
|
+
function asNumber(value) {
|
|
21
|
+
if (typeof value === 'number') {
|
|
22
|
+
return Number.isFinite(value) ? value : null;
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
25
|
+
const parsed = Number(value);
|
|
26
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function asDate(value) {
|
|
31
|
+
if (value instanceof Date) {
|
|
32
|
+
return Number.isNaN(value.getTime()) ? null : value;
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
35
|
+
const parsed = new Date(value);
|
|
36
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function formatDate(value, locale, style, withTime) {
|
|
41
|
+
const date = asDate(value);
|
|
42
|
+
if (!date) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const intl = globalThis.Intl;
|
|
46
|
+
if (!intl?.DateTimeFormat) {
|
|
47
|
+
return date.toISOString();
|
|
48
|
+
}
|
|
49
|
+
const dateStyle = DATE_STYLES[style ?? 'medium'] ?? 'medium';
|
|
50
|
+
const options = withTime
|
|
51
|
+
? { dateStyle, timeStyle: dateStyle === 'long' ? 'medium' : 'short' }
|
|
52
|
+
: { dateStyle };
|
|
53
|
+
return new intl.DateTimeFormat(locale, options).format(date);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Renders a value for display. A value the format cannot describe — a date field holding
|
|
57
|
+
* something that is not a date — falls back to its plain text rather than inventing a
|
|
58
|
+
* plausible-looking result.
|
|
59
|
+
*/
|
|
60
|
+
export function formatValue(value, format, locale = 'en-US') {
|
|
61
|
+
if (!format) {
|
|
62
|
+
return toText(value);
|
|
63
|
+
}
|
|
64
|
+
if (format.kind !== 'boolean' && !isPresent(value)) {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
switch (format.kind) {
|
|
68
|
+
case 'text':
|
|
69
|
+
return toText(value);
|
|
70
|
+
case 'number': {
|
|
71
|
+
const numeric = asNumber(value);
|
|
72
|
+
if (numeric === null) {
|
|
73
|
+
return toText(value);
|
|
74
|
+
}
|
|
75
|
+
return numberFormat(locale, {
|
|
76
|
+
useGrouping: format.grouping !== false,
|
|
77
|
+
...(format.decimals === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { minimumFractionDigits: format.decimals, maximumFractionDigits: format.decimals }),
|
|
80
|
+
})(numeric);
|
|
81
|
+
}
|
|
82
|
+
case 'currency': {
|
|
83
|
+
const numeric = asNumber(value);
|
|
84
|
+
if (numeric === null) {
|
|
85
|
+
return toText(value);
|
|
86
|
+
}
|
|
87
|
+
return numberFormat(locale, {
|
|
88
|
+
style: 'currency',
|
|
89
|
+
currency: format.currency,
|
|
90
|
+
...(format.decimals === undefined
|
|
91
|
+
? {}
|
|
92
|
+
: { minimumFractionDigits: format.decimals, maximumFractionDigits: format.decimals }),
|
|
93
|
+
})(numeric);
|
|
94
|
+
}
|
|
95
|
+
case 'percentage': {
|
|
96
|
+
const numeric = asNumber(value);
|
|
97
|
+
if (numeric === null) {
|
|
98
|
+
return toText(value);
|
|
99
|
+
}
|
|
100
|
+
// A percentage may be stored as a fraction or as a whole number of percent.
|
|
101
|
+
const fraction = format.scale === 'percent' ? numeric / 100 : numeric;
|
|
102
|
+
return numberFormat(locale, {
|
|
103
|
+
style: 'percent',
|
|
104
|
+
...(format.decimals === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { minimumFractionDigits: format.decimals, maximumFractionDigits: format.decimals }),
|
|
107
|
+
})(fraction);
|
|
108
|
+
}
|
|
109
|
+
case 'boolean': {
|
|
110
|
+
if (!isPresent(value)) {
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
return value ? format.trueLabel ?? 'Yes' : format.falseLabel ?? 'No';
|
|
114
|
+
}
|
|
115
|
+
case 'date':
|
|
116
|
+
return formatDate(value, locale, format.style, false) ?? toText(value);
|
|
117
|
+
case 'datetime':
|
|
118
|
+
return formatDate(value, locale, format.style, true) ?? toText(value);
|
|
119
|
+
default:
|
|
120
|
+
return toText(value);
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export * from './mutation/store.js';
|
|
|
4
4
|
export * from './mutation/transaction.js';
|
|
5
5
|
export * from './mutation/resolve-location.js';
|
|
6
6
|
export * from './mutation/mutation-engine.js';
|
|
7
|
+
export * from './format.js';
|
|
8
|
+
export * from './presentation-classes.js';
|
|
7
9
|
export * from './runtime.js';
|
|
8
10
|
export * from './memory-host.js';
|
|
9
11
|
export * from './source.js';
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export * from './mutation/store.js';
|
|
|
4
4
|
export * from './mutation/transaction.js';
|
|
5
5
|
export * from './mutation/resolve-location.js';
|
|
6
6
|
export * from './mutation/mutation-engine.js';
|
|
7
|
+
export * from './format.js';
|
|
8
|
+
export * from './presentation-classes.js';
|
|
7
9
|
export * from './runtime.js';
|
|
8
10
|
export * from './memory-host.js';
|
|
9
11
|
export * from './source.js';
|
package/dist/memory-host.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DomDocument, DomElement, DomEvent, DomListener, HostEnvironment, StorageAdapter } from './dom.js';
|
|
1
|
+
import type { ConfirmationRequest, DomDocument, DomElement, DomEvent, DomListener, HostEnvironment, StorageAdapter } from './dom.js';
|
|
2
2
|
/**
|
|
3
3
|
* An in-memory DOM and host. The runtime never touches browser globals directly, so the
|
|
4
4
|
* same renderer that drives a real page can be driven headlessly in tests.
|
|
@@ -36,6 +36,8 @@ export interface MemoryHost extends HostEnvironment {
|
|
|
36
36
|
path: string;
|
|
37
37
|
reports: string[];
|
|
38
38
|
confirmations: string[];
|
|
39
|
+
/** The structured confirmations asked for, so a test can assert what was presented. */
|
|
40
|
+
confirmationRequests: ConfirmationRequest[];
|
|
39
41
|
storage?: StorageAdapter;
|
|
40
42
|
}
|
|
41
43
|
export declare function createMemoryHost(options?: MemoryHostOptions): MemoryHost;
|
package/dist/memory-host.js
CHANGED
|
@@ -61,6 +61,7 @@ export function createMemoryHost(options = {}) {
|
|
|
61
61
|
path: options.path ?? '/',
|
|
62
62
|
reports: [],
|
|
63
63
|
confirmations: [],
|
|
64
|
+
confirmationRequests: [],
|
|
64
65
|
document: new MemoryDocument(),
|
|
65
66
|
getPath: () => host.path,
|
|
66
67
|
pushPath: (next) => {
|
|
@@ -73,6 +74,10 @@ export function createMemoryHost(options = {}) {
|
|
|
73
74
|
host.confirmations.push(message);
|
|
74
75
|
return typeof confirmResult === 'function' ? confirmResult() : confirmResult;
|
|
75
76
|
},
|
|
77
|
+
confirmRequest: (request) => {
|
|
78
|
+
host.confirmationRequests.push(request);
|
|
79
|
+
return host.confirm(request.message);
|
|
80
|
+
},
|
|
76
81
|
now: () => {
|
|
77
82
|
counter += 1;
|
|
78
83
|
return `2026-01-01T00:00:${String(counter).padStart(2, '0')}.000Z`;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResolvedPresentation, TextRole, UxRole } from '@cynodia/axiom-core';
|
|
2
|
+
/** Every class a node's resolved presentation implies, in a stable order. */
|
|
3
|
+
export declare function presentationClassList(resolved: ResolvedPresentation | undefined): string[];
|
|
4
|
+
export declare function presentationClasses(resolved: ResolvedPresentation | undefined, ...extra: string[]): string;
|
|
5
|
+
/**
|
|
6
|
+
* Landmarks. A UX role that names a region of the page becomes the element that means
|
|
7
|
+
* that region, so assistive technology gets the structure the graph declared.
|
|
8
|
+
*/
|
|
9
|
+
export declare function landmarkTag(uxRole: UxRole | undefined): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Headings are real headings, so the document has an outline rather than a set of large
|
|
12
|
+
* words. The three heading text roles are three levels: an application title, a title
|
|
13
|
+
* within it, and a section heading under that.
|
|
14
|
+
*/
|
|
15
|
+
export declare function headingTag(textRole: TextRole | undefined): string | undefined;
|
|
16
|
+
/** The ARIA role a UX role implies, where one is warranted. */
|
|
17
|
+
export declare function ariaRoleFor(uxRole: UxRole | undefined): string | undefined;
|
|
18
|
+
//# sourceMappingURL=presentation-classes.d.ts.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web renderer's translation of resolved presentation into class names.
|
|
3
|
+
*
|
|
4
|
+
* The renderer emits semantic classes and nothing else — no inline styles, no computed
|
|
5
|
+
* lengths, no colours. What those classes mean is decided by the generated stylesheet, so
|
|
6
|
+
* the same resolved presentation can drive an entirely different renderer.
|
|
7
|
+
*/
|
|
8
|
+
function layoutClasses(prefix, layout) {
|
|
9
|
+
const classes = [`axiom-${prefix}layout-${layout.kind}`, `axiom-${prefix}gap-${layout.gap}`];
|
|
10
|
+
if (!prefix) {
|
|
11
|
+
classes.push(`axiom-align-${layout.align}`, `axiom-justify-${layout.justify}`);
|
|
12
|
+
classes.push(layout.wrap ? 'axiom-wrap' : 'axiom-nowrap');
|
|
13
|
+
if (layout.columns !== undefined) {
|
|
14
|
+
classes.push(typeof layout.columns === 'number'
|
|
15
|
+
? `axiom-columns-${layout.columns}`
|
|
16
|
+
: `axiom-columns-adaptive-${layout.columns.minimum}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return classes;
|
|
20
|
+
}
|
|
21
|
+
function sizingClasses(prefix, sizing) {
|
|
22
|
+
const classes = [`axiom-${prefix}width-${sizing.width}`];
|
|
23
|
+
if (!prefix) {
|
|
24
|
+
classes.push(`axiom-height-${sizing.height}`);
|
|
25
|
+
if (sizing.minWidth) {
|
|
26
|
+
classes.push(`axiom-minwidth-${sizing.minWidth}`);
|
|
27
|
+
}
|
|
28
|
+
if (sizing.maxWidth) {
|
|
29
|
+
classes.push(`axiom-maxwidth-${sizing.maxWidth}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return classes;
|
|
33
|
+
}
|
|
34
|
+
function paddingClasses(prefix, padding) {
|
|
35
|
+
return [`axiom-${prefix}pad-x-${padding.horizontal}`, `axiom-${prefix}pad-y-${padding.vertical}`];
|
|
36
|
+
}
|
|
37
|
+
function responsiveClasses(device, override) {
|
|
38
|
+
const prefix = `${device}-`;
|
|
39
|
+
const classes = [];
|
|
40
|
+
if (override.hidden) {
|
|
41
|
+
classes.push(`axiom-${prefix}hidden`);
|
|
42
|
+
}
|
|
43
|
+
if (override.layout) {
|
|
44
|
+
classes.push(...layoutClasses(prefix, override.layout));
|
|
45
|
+
}
|
|
46
|
+
if (override.sizing) {
|
|
47
|
+
classes.push(...sizingClasses(prefix, override.sizing));
|
|
48
|
+
}
|
|
49
|
+
if (override.padding) {
|
|
50
|
+
classes.push(...paddingClasses(prefix, override.padding));
|
|
51
|
+
}
|
|
52
|
+
if (override.density) {
|
|
53
|
+
classes.push(`axiom-${prefix}density-${override.density}`);
|
|
54
|
+
}
|
|
55
|
+
return classes;
|
|
56
|
+
}
|
|
57
|
+
/** Every class a node's resolved presentation implies, in a stable order. */
|
|
58
|
+
export function presentationClassList(resolved) {
|
|
59
|
+
if (!resolved) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const classes = [
|
|
63
|
+
`axiom-density-${resolved.density}`,
|
|
64
|
+
`axiom-emphasis-${resolved.emphasis}`,
|
|
65
|
+
`axiom-surface-${resolved.surface}`,
|
|
66
|
+
`axiom-text-${resolved.textRole}`,
|
|
67
|
+
`axiom-treatment-${resolved.treatment}`,
|
|
68
|
+
];
|
|
69
|
+
if (resolved.role) {
|
|
70
|
+
classes.push(`axiom-role-${resolved.role}`);
|
|
71
|
+
}
|
|
72
|
+
if (resolved.uxRole) {
|
|
73
|
+
classes.push(`axiom-ux-${resolved.uxRole}`);
|
|
74
|
+
}
|
|
75
|
+
classes.push(...layoutClasses('', resolved.layout));
|
|
76
|
+
classes.push(...sizingClasses('', resolved.sizing));
|
|
77
|
+
classes.push(...paddingClasses('', resolved.padding));
|
|
78
|
+
for (const device of ['compact', 'regular', 'wide']) {
|
|
79
|
+
const override = resolved.responsive[device];
|
|
80
|
+
if (override) {
|
|
81
|
+
classes.push(...responsiveClasses(device, override));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// The escape hatch: a renderer-specific class, attached and otherwise not understood.
|
|
85
|
+
const override = resolved.rendererOverrides?.web;
|
|
86
|
+
if (override && typeof override.className === 'string' && override.className.trim()) {
|
|
87
|
+
classes.push('axiom-opaque', ...override.className.trim().split(/\s+/));
|
|
88
|
+
}
|
|
89
|
+
return classes;
|
|
90
|
+
}
|
|
91
|
+
export function presentationClasses(resolved, ...extra) {
|
|
92
|
+
return [...extra, ...presentationClassList(resolved)].filter(Boolean).join(' ');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Landmarks. A UX role that names a region of the page becomes the element that means
|
|
96
|
+
* that region, so assistive technology gets the structure the graph declared.
|
|
97
|
+
*/
|
|
98
|
+
export function landmarkTag(uxRole) {
|
|
99
|
+
switch (uxRole) {
|
|
100
|
+
case 'header-region':
|
|
101
|
+
return 'header';
|
|
102
|
+
case 'footer-region':
|
|
103
|
+
return 'footer';
|
|
104
|
+
case 'navigation-group':
|
|
105
|
+
return 'nav';
|
|
106
|
+
case 'content-region':
|
|
107
|
+
return 'main';
|
|
108
|
+
case 'sidebar':
|
|
109
|
+
return 'aside';
|
|
110
|
+
case 'form-section':
|
|
111
|
+
return 'section';
|
|
112
|
+
default:
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Headings are real headings, so the document has an outline rather than a set of large
|
|
118
|
+
* words. The three heading text roles are three levels: an application title, a title
|
|
119
|
+
* within it, and a section heading under that.
|
|
120
|
+
*/
|
|
121
|
+
export function headingTag(textRole) {
|
|
122
|
+
switch (textRole) {
|
|
123
|
+
case 'display':
|
|
124
|
+
return 'h1';
|
|
125
|
+
case 'title':
|
|
126
|
+
return 'h2';
|
|
127
|
+
case 'heading':
|
|
128
|
+
return 'h3';
|
|
129
|
+
default:
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** The ARIA role a UX role implies, where one is warranted. */
|
|
134
|
+
export function ariaRoleFor(uxRole) {
|
|
135
|
+
switch (uxRole) {
|
|
136
|
+
case 'toolbar':
|
|
137
|
+
return 'toolbar';
|
|
138
|
+
case 'error-state':
|
|
139
|
+
return 'alert';
|
|
140
|
+
case 'warning-state':
|
|
141
|
+
case 'success-state':
|
|
142
|
+
case 'informational-state':
|
|
143
|
+
return 'status';
|
|
144
|
+
default:
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/runtime.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { formatValue } from './format.js';
|
|
2
|
+
import { ariaRoleFor, headingTag, landmarkTag, presentationClasses, } from './presentation-classes.js';
|
|
1
3
|
import { createMutationEngine } from './mutation/mutation-engine.js';
|
|
2
4
|
import { LocationResolutionError, resolveLocation } from './mutation/resolve-location.js';
|
|
3
5
|
import { createStateStore } from './mutation/store.js';
|
|
@@ -96,6 +98,14 @@ export function createAxiomRuntime(options) {
|
|
|
96
98
|
let transactionCounter = 0;
|
|
97
99
|
const mutationLog = [];
|
|
98
100
|
const inputValidation = options.inputValidation ?? 'immediate';
|
|
101
|
+
const theme = ir.theme;
|
|
102
|
+
const locale = theme?.locale ?? 'en-US';
|
|
103
|
+
/** Messages for inputs whose last write was refused, so a control can say so. */
|
|
104
|
+
const inputErrors = new Map();
|
|
105
|
+
/** Presentation, already resolved by the compiler. The renderer decides nothing. */
|
|
106
|
+
function presentationOf(id) {
|
|
107
|
+
return ir.presentation?.[id];
|
|
108
|
+
}
|
|
99
109
|
const statesById = new Map(ir.states.map((state) => [state.id, state]));
|
|
100
110
|
const entitiesById = new Map(ir.entities.map((entity) => [entity.id, entity]));
|
|
101
111
|
const parameterTypes = new Map();
|
|
@@ -529,9 +539,9 @@ export function createAxiomRuntime(options) {
|
|
|
529
539
|
}
|
|
530
540
|
};
|
|
531
541
|
for (const state of ir.states) {
|
|
532
|
-
// Drafts are incomplete by definition,
|
|
533
|
-
//
|
|
534
|
-
if (state.draft || state.derivation) {
|
|
542
|
+
// Drafts are incomplete by definition, ephemeral state is not a domain fact at all,
|
|
543
|
+
// and derived states are views of data already validated where it is stored.
|
|
544
|
+
if (state.draft || state.ephemeral || state.derivation) {
|
|
535
545
|
continue;
|
|
536
546
|
}
|
|
537
547
|
visit(read(state.id), state.valueType);
|
|
@@ -897,8 +907,7 @@ export function createAxiomRuntime(options) {
|
|
|
897
907
|
return { ok: false, diagnostics: [...collected] };
|
|
898
908
|
}
|
|
899
909
|
if (action.requiresConfirmation) {
|
|
900
|
-
|
|
901
|
-
if (!host.confirm(message)) {
|
|
910
|
+
if (!askForConfirmation(action)) {
|
|
902
911
|
return { ok: false, diagnostics: [...collected] };
|
|
903
912
|
}
|
|
904
913
|
}
|
|
@@ -1007,18 +1016,49 @@ export function createAxiomRuntime(options) {
|
|
|
1007
1016
|
}
|
|
1008
1017
|
return created;
|
|
1009
1018
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1019
|
+
/** The semantic classes a node's resolved presentation implies. No styles, no colours. */
|
|
1020
|
+
function nodeClasses(node, ...base) {
|
|
1021
|
+
return presentationClasses(presentationOf(node.id), ...base);
|
|
1022
|
+
}
|
|
1023
|
+
/** A semantic icon, drawn with the glyph the theme supplies for it. */
|
|
1024
|
+
function appendIcon(parent, resolved) {
|
|
1025
|
+
const name = resolved?.icon;
|
|
1026
|
+
if (!name) {
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
const icon = element('span', 'axiom-icon');
|
|
1030
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
1031
|
+
icon.setAttribute('data-icon', name);
|
|
1032
|
+
icon.textContent = theme?.icons?.[name] ?? '';
|
|
1033
|
+
parent.appendChild(icon);
|
|
1034
|
+
return true;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Asks for confirmation. The graph describes what is asked; the host decides how to ask.
|
|
1038
|
+
* A host that can only manage a plain question still gets a sentence to show.
|
|
1039
|
+
*/
|
|
1040
|
+
function askForConfirmation(action) {
|
|
1041
|
+
const declared = action.confirmation;
|
|
1042
|
+
const fallback = `Confirm ${action.name ?? action.id}. This cannot be undone.`;
|
|
1043
|
+
const composed = declared
|
|
1044
|
+
? [declared.title, declared.description].filter(Boolean).join(' — ')
|
|
1045
|
+
: '';
|
|
1046
|
+
const message = action.confirmationMessage ?? (composed || fallback);
|
|
1047
|
+
if (!host.confirmRequest) {
|
|
1048
|
+
return host.confirm(message);
|
|
1049
|
+
}
|
|
1050
|
+
const request = {
|
|
1051
|
+
actionId: action.id,
|
|
1052
|
+
title: declared?.title ?? action.name ?? action.id,
|
|
1053
|
+
confirmLabel: declared?.confirmLabel ?? 'Confirm',
|
|
1054
|
+
cancelLabel: declared?.cancelLabel ?? 'Cancel',
|
|
1055
|
+
severity: declared?.severity ?? (action.destructive ? 'destructive' : 'informational'),
|
|
1056
|
+
message,
|
|
1057
|
+
...(declared?.description ?? action.confirmationMessage
|
|
1058
|
+
? { description: declared?.description ?? action.confirmationMessage }
|
|
1059
|
+
: {}),
|
|
1060
|
+
};
|
|
1061
|
+
return host.confirmRequest(request);
|
|
1022
1062
|
}
|
|
1023
1063
|
function renderChildren(ids, scope, parent) {
|
|
1024
1064
|
for (const id of ids) {
|
|
@@ -1044,10 +1084,31 @@ export function createAxiomRuntime(options) {
|
|
|
1044
1084
|
return null;
|
|
1045
1085
|
}
|
|
1046
1086
|
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Which control edits this value. Semantic intent wins, then the older HTML-shaped
|
|
1089
|
+
* hint, then the type of the location the input is bound to.
|
|
1090
|
+
*/
|
|
1047
1091
|
function resolveInputTag(node) {
|
|
1048
1092
|
const located = ir.locationTypes[node.id];
|
|
1049
1093
|
const resolved = located ? unwrapType(located) : null;
|
|
1094
|
+
const enumValues = resolved?.kind === 'enum' ? resolved.values : [];
|
|
1050
1095
|
const hint = node.inputHint;
|
|
1096
|
+
switch (presentationOf(node.id)?.control) {
|
|
1097
|
+
case 'multiline':
|
|
1098
|
+
return { tag: 'textarea', variant: 'multiline' };
|
|
1099
|
+
case 'select':
|
|
1100
|
+
return { tag: 'select', options: enumValues, variant: 'select' };
|
|
1101
|
+
case 'radio-group':
|
|
1102
|
+
return { tag: 'radio-group', options: enumValues, variant: 'radio-group' };
|
|
1103
|
+
case 'switch':
|
|
1104
|
+
return { tag: 'input', type: 'checkbox', variant: 'switch' };
|
|
1105
|
+
case 'checkbox':
|
|
1106
|
+
return { tag: 'input', type: 'checkbox', variant: 'checkbox' };
|
|
1107
|
+
case 'stepper':
|
|
1108
|
+
return { tag: 'input', type: 'number', variant: 'stepper' };
|
|
1109
|
+
default:
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1051
1112
|
if (hint === 'multiline') {
|
|
1052
1113
|
return { tag: 'textarea' };
|
|
1053
1114
|
}
|
|
@@ -1055,7 +1116,7 @@ export function createAxiomRuntime(options) {
|
|
|
1055
1116
|
return { tag: 'select' };
|
|
1056
1117
|
}
|
|
1057
1118
|
if (hint === 'select' || (!hint && resolved?.kind === 'enum')) {
|
|
1058
|
-
return { tag: 'select', options:
|
|
1119
|
+
return { tag: 'select', options: enumValues };
|
|
1059
1120
|
}
|
|
1060
1121
|
if (hint === 'checkbox' || (!hint && resolved?.kind === 'primitive' && resolved.primitive === 'boolean')) {
|
|
1061
1122
|
return { tag: 'input', type: 'checkbox' };
|
|
@@ -1131,28 +1192,55 @@ export function createAxiomRuntime(options) {
|
|
|
1131
1192
|
if (node.visibleWhen && !toBoolean(evaluate(node.visibleWhen, scope))) {
|
|
1132
1193
|
return null;
|
|
1133
1194
|
}
|
|
1195
|
+
const presentation = presentationOf(node.id);
|
|
1134
1196
|
switch (node.kind) {
|
|
1135
1197
|
case 'view': {
|
|
1136
|
-
const container = element('div',
|
|
1198
|
+
const container = element('div', nodeClasses(node, 'axiom-view'));
|
|
1137
1199
|
container.setAttribute('data-node', node.id);
|
|
1138
1200
|
renderChildren(node.children, scope, container);
|
|
1139
1201
|
return container;
|
|
1140
1202
|
}
|
|
1141
1203
|
case 'container': {
|
|
1142
|
-
|
|
1204
|
+
// A UX role that names a region of the page becomes the element that means it, so
|
|
1205
|
+
// the landmark structure an author declared is the one assistive technology sees.
|
|
1206
|
+
const container = element(landmarkTag(presentation?.uxRole) ?? 'div', nodeClasses(node, 'axiom-container'));
|
|
1143
1207
|
container.setAttribute('data-node', node.id);
|
|
1208
|
+
const ariaRole = ariaRoleFor(presentation?.uxRole);
|
|
1209
|
+
if (ariaRole) {
|
|
1210
|
+
container.setAttribute('role', ariaRole);
|
|
1211
|
+
}
|
|
1212
|
+
if (presentation?.accessibleLabel) {
|
|
1213
|
+
container.setAttribute('aria-label', presentation.accessibleLabel);
|
|
1214
|
+
}
|
|
1215
|
+
appendIcon(container, presentation);
|
|
1144
1216
|
renderChildren(node.children, scope, container);
|
|
1145
1217
|
return container;
|
|
1146
1218
|
}
|
|
1147
1219
|
case 'text': {
|
|
1148
|
-
const text = element('span',
|
|
1220
|
+
const text = element(headingTag(presentation?.textRole) ?? 'span', nodeClasses(node, 'axiom-text'));
|
|
1149
1221
|
text.setAttribute('data-node', node.id);
|
|
1150
|
-
|
|
1151
|
-
|
|
1222
|
+
// A status role announces itself whatever kind of node carries it.
|
|
1223
|
+
const textRole = ariaRoleFor(presentation?.uxRole);
|
|
1224
|
+
if (textRole) {
|
|
1225
|
+
text.setAttribute('role', textRole);
|
|
1226
|
+
}
|
|
1227
|
+
if (presentation?.accessibleLabel) {
|
|
1228
|
+
text.setAttribute('aria-label', presentation.accessibleLabel);
|
|
1229
|
+
}
|
|
1230
|
+
const raw = typeof node.value === 'string' ? node.value : evaluate(node.value, scope);
|
|
1231
|
+
const rendered = presentation?.format ? formatValue(raw, presentation.format, locale) : toText(raw);
|
|
1232
|
+
if (appendIcon(text, presentation)) {
|
|
1233
|
+
const value = element('span', 'axiom-text-value');
|
|
1234
|
+
value.textContent = rendered;
|
|
1235
|
+
text.appendChild(value);
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
text.textContent = rendered;
|
|
1239
|
+
}
|
|
1152
1240
|
return text;
|
|
1153
1241
|
}
|
|
1154
1242
|
case 'repeat': {
|
|
1155
|
-
const container = element('div', 'axiom-repeat');
|
|
1243
|
+
const container = element('div', nodeClasses(node, 'axiom-repeat'));
|
|
1156
1244
|
container.setAttribute('data-node', node.id);
|
|
1157
1245
|
const source = evaluate(node.source, scope);
|
|
1158
1246
|
const items = Array.isArray(source) ? source : [];
|
|
@@ -1169,7 +1257,7 @@ export function createAxiomRuntime(options) {
|
|
|
1169
1257
|
return container;
|
|
1170
1258
|
}
|
|
1171
1259
|
case 'field-display': {
|
|
1172
|
-
const container = element('div', 'axiom-field');
|
|
1260
|
+
const container = element('div', nodeClasses(node, 'axiom-field'));
|
|
1173
1261
|
container.setAttribute('data-node', node.id);
|
|
1174
1262
|
const field = fieldOf(node.fieldId);
|
|
1175
1263
|
if (node.label ?? field?.name) {
|
|
@@ -1177,21 +1265,27 @@ export function createAxiomRuntime(options) {
|
|
|
1177
1265
|
label.textContent = node.label ?? field?.name ?? '';
|
|
1178
1266
|
container.appendChild(label);
|
|
1179
1267
|
}
|
|
1268
|
+
appendIcon(container, presentation);
|
|
1180
1269
|
const value = element('span', 'axiom-field-value');
|
|
1181
1270
|
const source = evaluate(node.source, scope);
|
|
1182
|
-
value
|
|
1271
|
+
// The stored value is untouched; only what is shown is formatted.
|
|
1272
|
+
value.textContent = isRecord(source)
|
|
1273
|
+
? formatValue(source[node.fieldId], presentation?.format, locale)
|
|
1274
|
+
: '';
|
|
1183
1275
|
container.appendChild(value);
|
|
1184
1276
|
return container;
|
|
1185
1277
|
}
|
|
1186
1278
|
case 'form': {
|
|
1187
|
-
const form = element('form', 'axiom-form');
|
|
1279
|
+
const form = element('form', nodeClasses(node, 'axiom-form'));
|
|
1188
1280
|
form.setAttribute('data-node', node.id);
|
|
1189
1281
|
renderChildren(node.children, scope, form);
|
|
1190
1282
|
if (node.submitActionId) {
|
|
1191
|
-
const
|
|
1283
|
+
const actions = element('div', 'axiom-container axiom-ux-action-group axiom-layout-horizontal axiom-gap-small axiom-align-center axiom-justify-end axiom-wrap axiom-width-fill');
|
|
1284
|
+
const submit = element('button', 'axiom-submit axiom-button axiom-role-primary axiom-emphasis-strong axiom-ux-primary-action');
|
|
1192
1285
|
submit.setAttribute('type', 'submit');
|
|
1193
1286
|
submit.textContent = node.submitLabel ?? 'Submit';
|
|
1194
|
-
|
|
1287
|
+
actions.appendChild(submit);
|
|
1288
|
+
form.appendChild(actions);
|
|
1195
1289
|
const actionId = node.submitActionId;
|
|
1196
1290
|
form.addEventListener('submit', (event) => {
|
|
1197
1291
|
event.preventDefault?.();
|
|
@@ -1201,25 +1295,86 @@ export function createAxiomRuntime(options) {
|
|
|
1201
1295
|
return form;
|
|
1202
1296
|
}
|
|
1203
1297
|
case 'input': {
|
|
1204
|
-
const
|
|
1298
|
+
const descriptor = resolveInputTag(node);
|
|
1299
|
+
const grouped = descriptor.tag === 'radio-group';
|
|
1300
|
+
const controlId = `axiom-control-${node.id}`;
|
|
1301
|
+
const required = ir.locationRequired?.[node.id] === true;
|
|
1302
|
+
const wrapper = element(grouped ? 'div' : 'label', nodeClasses(node, 'axiom-input'));
|
|
1205
1303
|
wrapper.setAttribute('data-node', node.id);
|
|
1206
|
-
if (
|
|
1207
|
-
|
|
1208
|
-
|
|
1304
|
+
if (grouped) {
|
|
1305
|
+
wrapper.setAttribute('role', 'group');
|
|
1306
|
+
}
|
|
1307
|
+
else {
|
|
1308
|
+
// The label is the element, and it names the control by id as well, so the
|
|
1309
|
+
// association survives however the two are laid out.
|
|
1310
|
+
wrapper.setAttribute('for', controlId);
|
|
1311
|
+
}
|
|
1312
|
+
const labelText = node.label ?? presentation?.accessibleLabel;
|
|
1313
|
+
if (labelText) {
|
|
1314
|
+
const label = element('span', 'axiom-input-label axiom-text-label');
|
|
1315
|
+
label.textContent = labelText;
|
|
1316
|
+
if (required) {
|
|
1317
|
+
const marker = element('span', 'axiom-required-marker');
|
|
1318
|
+
marker.setAttribute('aria-hidden', 'true');
|
|
1319
|
+
marker.textContent = '*';
|
|
1320
|
+
label.appendChild(marker);
|
|
1321
|
+
}
|
|
1209
1322
|
wrapper.appendChild(label);
|
|
1210
1323
|
}
|
|
1211
|
-
const
|
|
1212
|
-
const
|
|
1324
|
+
const current = readLocation(node.binding.location, scope);
|
|
1325
|
+
const describedBy = [];
|
|
1326
|
+
const control = element(grouped ? 'div' : descriptor.tag, grouped ? 'axiom-radio-group' : 'axiom-control');
|
|
1213
1327
|
control.setAttribute('data-node', node.id);
|
|
1214
|
-
if (descriptor.
|
|
1215
|
-
control.setAttribute('
|
|
1328
|
+
if (descriptor.variant) {
|
|
1329
|
+
control.setAttribute('data-control', descriptor.variant);
|
|
1216
1330
|
}
|
|
1217
|
-
if (
|
|
1218
|
-
control.setAttribute('
|
|
1331
|
+
if (!grouped) {
|
|
1332
|
+
control.setAttribute('id', controlId);
|
|
1333
|
+
if (descriptor.type) {
|
|
1334
|
+
control.setAttribute('type', descriptor.type);
|
|
1335
|
+
}
|
|
1336
|
+
if (node.placeholder) {
|
|
1337
|
+
control.setAttribute('placeholder', node.placeholder);
|
|
1338
|
+
}
|
|
1339
|
+
if (required) {
|
|
1340
|
+
control.setAttribute('aria-required', 'true');
|
|
1341
|
+
}
|
|
1342
|
+
if (!labelText && presentation?.accessibleLabel) {
|
|
1343
|
+
control.setAttribute('aria-label', presentation.accessibleLabel);
|
|
1344
|
+
}
|
|
1219
1345
|
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1346
|
+
if (grouped) {
|
|
1347
|
+
wrapper.setAttribute('data-control', descriptor.variant ?? 'radio-group');
|
|
1348
|
+
}
|
|
1349
|
+
if (descriptor.variant === 'switch') {
|
|
1350
|
+
control.setAttribute('role', 'switch');
|
|
1351
|
+
}
|
|
1352
|
+
const radios = [];
|
|
1353
|
+
if (grouped) {
|
|
1354
|
+
for (const choice of optionChoices(node, scope, descriptor.options ?? [])) {
|
|
1355
|
+
const option = element('label', 'axiom-radio-option');
|
|
1356
|
+
const radio = element('input', 'axiom-radio');
|
|
1357
|
+
radios.push(radio);
|
|
1358
|
+
radio.setAttribute('type', 'radio');
|
|
1359
|
+
radio.setAttribute('name', controlId);
|
|
1360
|
+
radio.setAttribute('value', choice.value);
|
|
1361
|
+
radio.value = choice.value;
|
|
1362
|
+
if (toText(current) === choice.value) {
|
|
1363
|
+
radio.checked = true;
|
|
1364
|
+
radio.setAttribute('checked', 'checked');
|
|
1365
|
+
}
|
|
1366
|
+
const caption = element('span');
|
|
1367
|
+
caption.textContent = choice.label;
|
|
1368
|
+
option.appendChild(radio);
|
|
1369
|
+
option.appendChild(caption);
|
|
1370
|
+
control.appendChild(option);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
else if (descriptor.type === 'checkbox') {
|
|
1222
1374
|
control.checked = Boolean(current);
|
|
1375
|
+
if (Boolean(current)) {
|
|
1376
|
+
control.setAttribute('checked', 'checked');
|
|
1377
|
+
}
|
|
1223
1378
|
}
|
|
1224
1379
|
else if (descriptor.tag === 'select') {
|
|
1225
1380
|
for (const choice of optionChoices(node, scope, descriptor.options ?? [])) {
|
|
@@ -1248,7 +1403,12 @@ export function createAxiomRuntime(options) {
|
|
|
1248
1403
|
const next = coerceInputValue(node.id, source.value ?? '', source.checked);
|
|
1249
1404
|
const rootStateId = ir.locationRoots[node.id];
|
|
1250
1405
|
const rootState = rootStateId === undefined ? undefined : statesById.get(rootStateId);
|
|
1251
|
-
|
|
1406
|
+
// A draft is incomplete while it is filled in, and ephemeral presentation state
|
|
1407
|
+
// is not a domain fact at all; neither is guarded per keystroke.
|
|
1408
|
+
const guarded = inputValidation === 'immediate' &&
|
|
1409
|
+
rootState !== undefined &&
|
|
1410
|
+
rootState.draft !== true &&
|
|
1411
|
+
rootState.ephemeral !== true;
|
|
1252
1412
|
const before = guarded ? countViolations(hardViolations()) : null;
|
|
1253
1413
|
const transaction = transactions.begin();
|
|
1254
1414
|
const context = {
|
|
@@ -1260,6 +1420,7 @@ export function createAxiomRuntime(options) {
|
|
|
1260
1420
|
mutate(() => mutations.set(node.binding.location, next, scope, context), context, failures);
|
|
1261
1421
|
if (failures.length > 0) {
|
|
1262
1422
|
settle(transaction, 'rolled-back');
|
|
1423
|
+
inputErrors.set(node.id, failures[0].message);
|
|
1263
1424
|
failures.forEach(report);
|
|
1264
1425
|
}
|
|
1265
1426
|
else {
|
|
@@ -1271,6 +1432,7 @@ export function createAxiomRuntime(options) {
|
|
|
1271
1432
|
];
|
|
1272
1433
|
if (rejected.length > 0) {
|
|
1273
1434
|
settle(transaction, 'rolled-back');
|
|
1435
|
+
inputErrors.set(node.id, rejected[0].message);
|
|
1274
1436
|
rejected.forEach((diagnostic) => report({ ...diagnostic, details: { ...diagnostic.details, source: 'input', nodeId: node.id } }));
|
|
1275
1437
|
report({
|
|
1276
1438
|
code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
|
|
@@ -1282,27 +1444,64 @@ export function createAxiomRuntime(options) {
|
|
|
1282
1444
|
}
|
|
1283
1445
|
else {
|
|
1284
1446
|
settle(transaction, 'committed');
|
|
1447
|
+
inputErrors.delete(node.id);
|
|
1285
1448
|
}
|
|
1286
1449
|
}
|
|
1287
1450
|
focusedNodeId = node.id;
|
|
1288
1451
|
focusedCaret = typeof source.selectionStart === 'number' ? source.selectionStart : null;
|
|
1289
1452
|
renderApplication();
|
|
1290
1453
|
};
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1454
|
+
// A radio group's listeners live on its radios; everything else is one control.
|
|
1455
|
+
for (const target of grouped ? radios : [control]) {
|
|
1456
|
+
target.addEventListener('input', apply);
|
|
1457
|
+
target.addEventListener('change', apply);
|
|
1458
|
+
target.addEventListener('focus', () => {
|
|
1459
|
+
focusedNodeId = node.id;
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1296
1462
|
inputElements.set(node.id, control);
|
|
1297
1463
|
wrapper.appendChild(control);
|
|
1464
|
+
if (presentation?.description) {
|
|
1465
|
+
const help = element('span', 'axiom-input-description axiom-text-caption');
|
|
1466
|
+
help.setAttribute('id', `${controlId}-description`);
|
|
1467
|
+
help.textContent = presentation.description;
|
|
1468
|
+
describedBy.push(`${controlId}-description`);
|
|
1469
|
+
wrapper.appendChild(help);
|
|
1470
|
+
}
|
|
1471
|
+
const rejection = inputErrors.get(node.id);
|
|
1472
|
+
if (rejection) {
|
|
1473
|
+
// The refusal is related to the control it refused, not left floating.
|
|
1474
|
+
const error = element('span', 'axiom-input-description axiom-role-destructive axiom-text-caption');
|
|
1475
|
+
error.setAttribute('id', `${controlId}-error`);
|
|
1476
|
+
error.setAttribute('role', 'alert');
|
|
1477
|
+
error.textContent = rejection;
|
|
1478
|
+
describedBy.push(`${controlId}-error`);
|
|
1479
|
+
wrapper.appendChild(error);
|
|
1480
|
+
if (!grouped) {
|
|
1481
|
+
control.setAttribute('aria-invalid', 'true');
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (describedBy.length > 0 && !grouped) {
|
|
1485
|
+
control.setAttribute('aria-describedby', describedBy.join(' '));
|
|
1486
|
+
}
|
|
1298
1487
|
return wrapper;
|
|
1299
1488
|
}
|
|
1300
1489
|
case 'button': {
|
|
1301
|
-
const button = element('button',
|
|
1490
|
+
const button = element('button', nodeClasses(node, 'axiom-button'));
|
|
1302
1491
|
button.setAttribute('data-node', node.id);
|
|
1303
1492
|
button.setAttribute('type', 'button');
|
|
1304
|
-
|
|
1305
|
-
|
|
1493
|
+
const label = typeof node.label === 'string' ? node.label : toText(evaluate(node.label, scope));
|
|
1494
|
+
if (appendIcon(button, presentation)) {
|
|
1495
|
+
const caption = element('span', 'axiom-button-label');
|
|
1496
|
+
caption.textContent = label;
|
|
1497
|
+
button.appendChild(caption);
|
|
1498
|
+
}
|
|
1499
|
+
else {
|
|
1500
|
+
button.textContent = label;
|
|
1501
|
+
}
|
|
1502
|
+
if (presentation?.accessibleLabel) {
|
|
1503
|
+
button.setAttribute('aria-label', presentation.accessibleLabel);
|
|
1504
|
+
}
|
|
1306
1505
|
button.addEventListener('click', (event) => {
|
|
1307
1506
|
event.preventDefault?.();
|
|
1308
1507
|
const args = {};
|
|
@@ -1314,7 +1513,7 @@ export function createAxiomRuntime(options) {
|
|
|
1314
1513
|
return button;
|
|
1315
1514
|
}
|
|
1316
1515
|
case 'conditional': {
|
|
1317
|
-
const container = element('div', 'axiom-conditional');
|
|
1516
|
+
const container = element('div', nodeClasses(node, 'axiom-conditional'));
|
|
1318
1517
|
container.setAttribute('data-node', node.id);
|
|
1319
1518
|
const branch = toBoolean(evaluate(node.condition, scope)) ? node.whenTrue : node.whenFalse ?? [];
|
|
1320
1519
|
renderChildren(branch, scope, container);
|
package/dist/source.js
CHANGED
|
@@ -11,6 +11,8 @@ const RUNTIME_MODULES = [
|
|
|
11
11
|
'./mutation/transaction.js',
|
|
12
12
|
'./mutation/resolve-location.js',
|
|
13
13
|
'./mutation/mutation-engine.js',
|
|
14
|
+
'./format.js',
|
|
15
|
+
'./presentation-classes.js',
|
|
14
16
|
'./runtime.js',
|
|
15
17
|
];
|
|
16
18
|
const BUNDLED_BASENAMES = RUNTIME_MODULES.map((module) => module.slice(module.lastIndexOf('/') + 1));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cynodia/axiom-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.5.0-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsc -b tsconfig.json tsconfig.test.json",
|