@intent-framework/dom 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +125 -0
- package/dist/dom-router.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +276 -118
- package/package.json +3 -3
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @intent-framework/dom
|
|
2
|
+
|
|
3
|
+
DOM materializer for Intent screens and router.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @intent-framework/core @intent-framework/dom
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @intent-framework/core @intent-framework/dom
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What it provides
|
|
16
|
+
|
|
17
|
+
- `renderDom()` — materialize a screen into semantic HTML
|
|
18
|
+
- `renderRouter()` — materialize a router into navigable DOM pages
|
|
19
|
+
- Real HTML labels, inputs, buttons, and `aria-live` output
|
|
20
|
+
- Reactive action enablement and blocked reasons
|
|
21
|
+
- Enter key triggers the default action when unambiguous
|
|
22
|
+
- Opt-in screen-name heading via `showScreenName`
|
|
23
|
+
- Opt-in semantic data attributes via `showSemanticIds`
|
|
24
|
+
|
|
25
|
+
## renderDom behavior
|
|
26
|
+
|
|
27
|
+
### Input types
|
|
28
|
+
|
|
29
|
+
| Ask kind | Renders as |
|
|
30
|
+
|----------|------------|
|
|
31
|
+
| `$.state.text()` / default | `<input type="text">` |
|
|
32
|
+
| `asContact("email")` | `<input type="email">` with `autocomplete="email"` |
|
|
33
|
+
| `asSecret()` | `<input type="password">` |
|
|
34
|
+
| `$.state.boolean()` | `<input type="checkbox">` |
|
|
35
|
+
| `$.state.choice()` with `asChoice()` | `<select>` with `<option>` elements |
|
|
36
|
+
|
|
37
|
+
### Boolean and choice asks
|
|
38
|
+
|
|
39
|
+
Boolean asks render as checkboxes. The checked state is synced with the underlying `BooleanState`.
|
|
40
|
+
|
|
41
|
+
Choice asks render as `<select>` elements. Options are populated from the `ChoiceState` options array. The selected value is synced with the underlying state.
|
|
42
|
+
|
|
43
|
+
### Blocked reasons
|
|
44
|
+
|
|
45
|
+
When an action is disabled due to blocked reasons, the renderer adds:
|
|
46
|
+
|
|
47
|
+
- `disabled` attribute on the button
|
|
48
|
+
- `<p class="intent-blocked-reason" role="alert">` with the first blocked reason text
|
|
49
|
+
- `aria-describedby` on the button pointing to the reason element
|
|
50
|
+
|
|
51
|
+
When the action becomes enabled, the reason element is removed from the DOM and `aria-describedby` is cleared.
|
|
52
|
+
|
|
53
|
+
### Single-surface output
|
|
54
|
+
|
|
55
|
+
When a screen has exactly one surface, the output is backward compatible: a single `<main>` element containing one `<form>` with all asks and actions. No surface name suffix is added to DOM ids.
|
|
56
|
+
|
|
57
|
+
### Multi-surface output
|
|
58
|
+
|
|
59
|
+
When a screen has multiple surfaces, each surface renders as a separate `<section>` with `aria-label` set to the surface name. Each section contains its own `<form>` with only the items belonging to that surface.
|
|
60
|
+
|
|
61
|
+
**DOM id suffixes:** All ask inputs, buttons, hint paragraphs, reason paragraphs, and feedback outputs get a `--<surfaceName>` suffix to avoid id collisions.
|
|
62
|
+
|
|
63
|
+
**Duplicate controls share state:** The same ask appearing in multiple surfaces shares the underlying state. Typing in one surface updates the input in all surfaces.
|
|
64
|
+
|
|
65
|
+
**Duplicate action buttons:** The same action appearing in multiple surfaces renders separate buttons, all executing the same `executeAct()` call.
|
|
66
|
+
|
|
67
|
+
**Per-surface feedback:** Each surface gets its own `<output aria-live="polite">` element. Action feedback is updated independently per surface.
|
|
68
|
+
|
|
69
|
+
**`data-intent-*` semantic ids:** The `data-intent-ask` and `data-intent-action` attributes remain the same across all copies — they identify the semantic node, not the DOM position.
|
|
70
|
+
|
|
71
|
+
### Enter key default action
|
|
72
|
+
|
|
73
|
+
When exactly one primary action exists (or exactly one action total), pressing Enter in any text input triggers that action. The Enter hint ("Press Enter to ...") appears reactively when the default action becomes enabled, and is hidden when disabled. Shift/ Meta/ Ctrl/ Alt + Enter are ignored. Enter is ignored on `<textarea>`, `<select>`, and checkbox inputs.
|
|
74
|
+
|
|
75
|
+
### Options
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
renderDom(Screen, {
|
|
79
|
+
target: document.getElementById("root")!,
|
|
80
|
+
showScreenName: true, // render screen name as <h1>
|
|
81
|
+
showSemanticIds: true, // add data-intent-screen, data-intent-ask, data-intent-action attributes
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Minimal example
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { screen } from "@intent-framework/core"
|
|
89
|
+
import { renderDom } from "@intent-framework/dom"
|
|
90
|
+
|
|
91
|
+
const InviteMember = screen("InviteMember", $ => {
|
|
92
|
+
const email = $.state.text("email")
|
|
93
|
+
|
|
94
|
+
const emailAsk = $.ask("Email", email)
|
|
95
|
+
.required()
|
|
96
|
+
.validate(value => value.includes("@") ? true : "Enter a valid email")
|
|
97
|
+
|
|
98
|
+
const invite = $.act("Invite member")
|
|
99
|
+
.primary()
|
|
100
|
+
.when(emailAsk.valid, "Enter a valid email first")
|
|
101
|
+
|
|
102
|
+
$.surface("main").contains(emailAsk, invite)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const cleanup = renderDom(InviteMember, {
|
|
106
|
+
target: document.getElementById("root")!,
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The renderer produces real DOM — labels, inputs, buttons, and an `aria-live` output. No JSX required.
|
|
111
|
+
|
|
112
|
+
## Where this fits
|
|
113
|
+
|
|
114
|
+
DOM is a renderer for Intent screens. It depends on `@intent-framework/core` and can optionally integrate with `@intent-framework/router` via `renderRouter()`. It is not the source of truth — the screen definition is.
|
|
115
|
+
|
|
116
|
+
## Learn more
|
|
117
|
+
|
|
118
|
+
- [Root README](../../README.md) — project overview and philosophy
|
|
119
|
+
- [Quickstart](../../docs/Quickstart.md) — step-by-step guide with DOM rendering
|
|
120
|
+
- [Semantic DOM Debugging](../../docs/Semantic-DOM-Debugging.md) — how `showSemanticIds` maps `inspectScreen()` IDs to DOM data attributes
|
|
121
|
+
- [Canonical runnable example](../../examples/canonical-invite) — matches the Quickstart one-to-one
|
|
122
|
+
|
|
123
|
+
## Status
|
|
124
|
+
|
|
125
|
+
Experimental alpha. APIs may change. Not recommended for production use.
|
package/dist/dom-router.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export type RenderRouterOptions<TServices extends object = DefaultScreenServices
|
|
|
13
13
|
notFound?: ScreenDefinition<TServices> | ((pathname: string) => ScreenDefinition<TServices>);
|
|
14
14
|
services?: Omit<TServices, "navigate" | "route">;
|
|
15
15
|
showScreenName?: boolean;
|
|
16
|
+
showSemanticIds?: boolean;
|
|
16
17
|
};
|
|
17
18
|
export declare function renderRouter<Routes extends Record<string, {
|
|
18
19
|
path: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type DomRendererOptions<TServices extends object = DefaultScreenServices>
|
|
|
3
3
|
target: HTMLElement;
|
|
4
4
|
services?: TServices;
|
|
5
5
|
showScreenName?: boolean;
|
|
6
|
+
showSemanticIds?: boolean;
|
|
6
7
|
};
|
|
7
8
|
export { renderRouter } from "./dom-router.js";
|
|
8
9
|
export type { RouterDomHandle, RenderRouterOptions } from "./dom-router.js";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { createScreenRuntime } from "@intent-framework/core";
|
|
1
|
+
import { createScreenRuntime, inspectScreen } from "@intent-framework/core";
|
|
2
2
|
|
|
3
3
|
//#region src/dom-router.ts
|
|
4
4
|
function renderRouter(router, options) {
|
|
5
|
-
const { showScreenName } = options;
|
|
5
|
+
const { showScreenName, showSemanticIds } = options;
|
|
6
6
|
const win = options.window ?? window;
|
|
7
7
|
let currentCleanup;
|
|
8
8
|
const navigate = (name, ...args) => {
|
|
@@ -30,7 +30,8 @@ function renderRouter(router, options) {
|
|
|
30
30
|
currentCleanup = renderDom(match.screen, {
|
|
31
31
|
target: options.target,
|
|
32
32
|
services: mergedServices,
|
|
33
|
-
showScreenName
|
|
33
|
+
showScreenName,
|
|
34
|
+
showSemanticIds
|
|
34
35
|
});
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
@@ -39,7 +40,8 @@ function renderRouter(router, options) {
|
|
|
39
40
|
currentCleanup = renderDom(screen, {
|
|
40
41
|
target: options.target,
|
|
41
42
|
services: mergedServices,
|
|
42
|
-
showScreenName
|
|
43
|
+
showScreenName,
|
|
44
|
+
showSemanticIds
|
|
43
45
|
});
|
|
44
46
|
} else options.target.textContent = "Not found";
|
|
45
47
|
}
|
|
@@ -61,15 +63,18 @@ function renderRouter(router, options) {
|
|
|
61
63
|
|
|
62
64
|
//#endregion
|
|
63
65
|
//#region src/index.ts
|
|
64
|
-
function getReasonId(actId) {
|
|
65
|
-
return `${actId}-reason`;
|
|
66
|
+
function getReasonId(actId, suffix = "") {
|
|
67
|
+
return `${actId}-reason${suffix}`;
|
|
66
68
|
}
|
|
67
|
-
function getEnterHintId(askId) {
|
|
68
|
-
return `${askId}-enter-hint`;
|
|
69
|
+
function getEnterHintId(askId, suffix = "") {
|
|
70
|
+
return `${askId}-enter-hint${suffix}`;
|
|
69
71
|
}
|
|
70
72
|
function sanitizeLabel(label) {
|
|
71
73
|
return label.replace(/\.+$/, "");
|
|
72
74
|
}
|
|
75
|
+
function surfaceSuffix(surfaceName, isMulti) {
|
|
76
|
+
return isMulti ? `--${surfaceName}` : "";
|
|
77
|
+
}
|
|
73
78
|
function findDefaultAction(acts) {
|
|
74
79
|
const primaryActs = acts.filter((a) => a.primary);
|
|
75
80
|
if (primaryActs.length === 1) return primaryActs[0];
|
|
@@ -77,27 +82,32 @@ function findDefaultAction(acts) {
|
|
|
77
82
|
return void 0;
|
|
78
83
|
}
|
|
79
84
|
function renderDom(screenDef, options) {
|
|
80
|
-
const { target, services, showScreenName } = options;
|
|
85
|
+
const { target, services, showScreenName, showSemanticIds } = options;
|
|
81
86
|
target.innerHTML = "";
|
|
82
|
-
const
|
|
87
|
+
const isMulti = screenDef.surfaces.length > 1;
|
|
88
|
+
const root = buildDom(screenDef, showScreenName, showSemanticIds);
|
|
83
89
|
target.appendChild(root);
|
|
84
90
|
const runtime = createScreenRuntime(screenDef, { services });
|
|
85
91
|
runtime.start();
|
|
86
|
-
const form = target.querySelector("form");
|
|
87
|
-
const output = target.querySelector("output#feedback-output");
|
|
88
92
|
const unsubscribers = [];
|
|
89
93
|
for (const act of screenDef.acts) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
const unsub = act.enabled.subscribe(() => {
|
|
95
|
+
for (const surface of screenDef.surfaces) {
|
|
96
|
+
const suffix = surfaceSuffix(surface.name, isMulti);
|
|
97
|
+
const form = getSurfaceForm(root, surface, isMulti);
|
|
98
|
+
if (!form) continue;
|
|
99
|
+
const button = form.querySelector(`#${act.id}${suffix}`);
|
|
100
|
+
if (!button) continue;
|
|
93
101
|
button.disabled = !act.enabled.current;
|
|
94
|
-
const reasonId = getReasonId(act.id);
|
|
102
|
+
const reasonId = getReasonId(act.id, suffix);
|
|
95
103
|
let reasonEl = form.querySelector(`#${reasonId}`);
|
|
96
104
|
if (!act.enabled.current && act.blockedReasons.length > 0) {
|
|
97
105
|
button.setAttribute("aria-describedby", reasonId);
|
|
98
106
|
if (!reasonEl) {
|
|
99
107
|
reasonEl = document.createElement("p");
|
|
100
108
|
reasonEl.id = reasonId;
|
|
109
|
+
reasonEl.className = "intent-blocked-reason";
|
|
110
|
+
reasonEl.setAttribute("role", "alert");
|
|
101
111
|
form.appendChild(reasonEl);
|
|
102
112
|
}
|
|
103
113
|
reasonEl.textContent = act.blockedReasons[0];
|
|
@@ -105,18 +115,27 @@ function renderDom(screenDef, options) {
|
|
|
105
115
|
button.removeAttribute("aria-describedby");
|
|
106
116
|
if (reasonEl) reasonEl.remove();
|
|
107
117
|
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
unsubscribers.push(unsub);
|
|
111
121
|
}
|
|
112
122
|
for (const act of screenDef.acts) {
|
|
113
123
|
const unsub = act.onStatusChange(() => {
|
|
114
|
-
|
|
124
|
+
for (const surface of screenDef.surfaces) {
|
|
125
|
+
const suffix = surfaceSuffix(surface.name, isMulti);
|
|
126
|
+
const form = getSurfaceForm(root, surface, isMulti);
|
|
127
|
+
if (!form) continue;
|
|
128
|
+
const output = form.querySelector(`#feedback-output${suffix}`);
|
|
129
|
+
if (output) updateFeedback(act, output);
|
|
130
|
+
}
|
|
115
131
|
});
|
|
116
132
|
unsubscribers.push(unsub);
|
|
117
133
|
}
|
|
118
|
-
for (const act of screenDef.acts) {
|
|
119
|
-
const
|
|
134
|
+
for (const act of screenDef.acts) for (const surface of screenDef.surfaces) {
|
|
135
|
+
const suffix = surfaceSuffix(surface.name, isMulti);
|
|
136
|
+
const form = getSurfaceForm(root, surface, isMulti);
|
|
137
|
+
if (!form) continue;
|
|
138
|
+
const button = form.querySelector(`#${act.id}${suffix}`);
|
|
120
139
|
if (button) button.addEventListener("click", () => {
|
|
121
140
|
if (act.enabled.current) runtime.executeAct(act);
|
|
122
141
|
});
|
|
@@ -125,126 +144,262 @@ function renderDom(screenDef, options) {
|
|
|
125
144
|
if (defaultActionForHint) {
|
|
126
145
|
const unsub = defaultActionForHint.enabled.subscribe(() => {
|
|
127
146
|
const isEnabled = defaultActionForHint.enabled.current;
|
|
128
|
-
for (const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
for (const surface of screenDef.surfaces) {
|
|
148
|
+
const suffix = surfaceSuffix(surface.name, isMulti);
|
|
149
|
+
const form = getSurfaceForm(root, surface, isMulti);
|
|
150
|
+
if (!form) continue;
|
|
151
|
+
for (const item of surface.items) {
|
|
152
|
+
if (!("state" in item)) continue;
|
|
153
|
+
const ask = item;
|
|
154
|
+
const input = form.querySelector(`#${ask.id}${suffix}`);
|
|
155
|
+
const hint = form.querySelector(`#${getEnterHintId(ask.id, suffix)}`);
|
|
156
|
+
if (input && hint) {
|
|
157
|
+
const hintId = getEnterHintId(ask.id, suffix);
|
|
158
|
+
if (isEnabled) {
|
|
159
|
+
hint.style.display = "";
|
|
160
|
+
const existing = input.getAttribute("aria-describedby") || "";
|
|
161
|
+
const ids = existing.split(/\s+/).filter(Boolean);
|
|
162
|
+
if (!ids.includes(hintId)) ids.push(hintId);
|
|
163
|
+
input.setAttribute("aria-describedby", ids.join(" "));
|
|
164
|
+
} else {
|
|
165
|
+
hint.style.display = "none";
|
|
166
|
+
const existing = input.getAttribute("aria-describedby") || "";
|
|
167
|
+
const ids = existing.split(/\s+/).filter(Boolean).filter((id) => id !== hintId);
|
|
168
|
+
if (ids.length > 0) input.setAttribute("aria-describedby", ids.join(" "));
|
|
169
|
+
else input.removeAttribute("aria-describedby");
|
|
170
|
+
}
|
|
145
171
|
}
|
|
146
172
|
}
|
|
147
173
|
}
|
|
148
174
|
});
|
|
149
175
|
unsubscribers.push(unsub);
|
|
150
176
|
}
|
|
151
|
-
for (const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
event
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
for (const surface of screenDef.surfaces) {
|
|
178
|
+
const suffix = surfaceSuffix(surface.name, isMulti);
|
|
179
|
+
const form = getSurfaceForm(root, surface, isMulti);
|
|
180
|
+
if (!form) continue;
|
|
181
|
+
for (const item of surface.items) {
|
|
182
|
+
if (!("state" in item)) continue;
|
|
183
|
+
const ask = item;
|
|
184
|
+
const input = form.querySelector(`#${ask.id}${suffix}`);
|
|
185
|
+
if (input) {
|
|
186
|
+
const onKeyDown = (event) => {
|
|
187
|
+
if (event.key !== "Enter") return;
|
|
188
|
+
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
189
|
+
if (input.tagName === "TEXTAREA") return;
|
|
190
|
+
if (input.tagName === "SELECT") return;
|
|
191
|
+
if (input.type === "checkbox") return;
|
|
192
|
+
const defaultAction = findDefaultAction(screenDef.acts);
|
|
193
|
+
if (!defaultAction || !defaultAction.enabled.current) return;
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
runtime.executeAct(defaultAction);
|
|
196
|
+
};
|
|
197
|
+
input.addEventListener("keydown", onKeyDown);
|
|
198
|
+
unsubscribers.push(() => input.removeEventListener("keydown", onKeyDown));
|
|
199
|
+
}
|
|
165
200
|
}
|
|
166
201
|
}
|
|
202
|
+
for (const ask of screenDef.asks) {
|
|
203
|
+
const unsub = ask.subscribe(() => {
|
|
204
|
+
for (const surface of screenDef.surfaces) {
|
|
205
|
+
const suffix = surfaceSuffix(surface.name, isMulti);
|
|
206
|
+
const form = getSurfaceForm(root, surface, isMulti);
|
|
207
|
+
if (!form) continue;
|
|
208
|
+
const control = form.querySelector(`#${ask.id}${suffix}`);
|
|
209
|
+
if (!control) continue;
|
|
210
|
+
if (typeof ask.state.value === "boolean") control.checked = ask.state.value;
|
|
211
|
+
else if (ask.kind === "choice") control.value = ask.state.value;
|
|
212
|
+
else control.value = ask.state.value;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
unsubscribers.push(unsub);
|
|
216
|
+
}
|
|
167
217
|
return () => {
|
|
168
218
|
for (const unsub of unsubscribers) unsub();
|
|
169
219
|
runtime.dispose();
|
|
170
220
|
};
|
|
171
221
|
}
|
|
172
|
-
function
|
|
173
|
-
|
|
222
|
+
function getSurfaceForm(root, surface, isMulti) {
|
|
223
|
+
if (isMulti) {
|
|
224
|
+
const section = root.querySelector(`#${surface.id}`);
|
|
225
|
+
return section ? section.querySelector("form") : null;
|
|
226
|
+
}
|
|
227
|
+
return root.querySelector("form");
|
|
228
|
+
}
|
|
229
|
+
function buildDom(screenDef, showScreenName, showSemanticIds) {
|
|
230
|
+
let inspected;
|
|
231
|
+
let askSemanticIds;
|
|
232
|
+
let actSemanticIds;
|
|
233
|
+
if (showSemanticIds) {
|
|
234
|
+
inspected = inspectScreen(screenDef);
|
|
235
|
+
askSemanticIds = new Map(inspected.asks.map((a) => [a.id, a.semanticId]));
|
|
236
|
+
actSemanticIds = new Map(inspected.acts.map((a) => [a.id, a.semanticId]));
|
|
237
|
+
}
|
|
238
|
+
const isMulti = screenDef.surfaces.length > 1;
|
|
174
239
|
const main = document.createElement("main");
|
|
175
|
-
if (
|
|
240
|
+
if (showSemanticIds && inspected) main.setAttribute("data-intent-screen", inspected.semanticId);
|
|
176
241
|
if (showScreenName) {
|
|
177
242
|
const heading = document.createElement("h1");
|
|
178
243
|
heading.textContent = screenDef.name;
|
|
179
244
|
main.appendChild(heading);
|
|
180
245
|
}
|
|
246
|
+
if (isMulti) for (const surface of screenDef.surfaces) {
|
|
247
|
+
const section = buildSurface(screenDef, surface, showSemanticIds, askSemanticIds, actSemanticIds, isMulti);
|
|
248
|
+
main.appendChild(section);
|
|
249
|
+
}
|
|
250
|
+
else if (screenDef.surfaces.length === 1) {
|
|
251
|
+
const surface = screenDef.surfaces[0];
|
|
252
|
+
main.id = surface.id;
|
|
253
|
+
const form = buildForm(screenDef, surface, showSemanticIds, askSemanticIds, actSemanticIds, isMulti);
|
|
254
|
+
main.appendChild(form);
|
|
255
|
+
}
|
|
256
|
+
return main;
|
|
257
|
+
}
|
|
258
|
+
function buildSurface(screenDef, surface, showSemanticIds, askSemanticIds, actSemanticIds, isMulti) {
|
|
259
|
+
const section = document.createElement("section");
|
|
260
|
+
section.id = surface.id;
|
|
261
|
+
section.setAttribute("aria-label", surface.name);
|
|
262
|
+
const form = buildForm(screenDef, surface, showSemanticIds, askSemanticIds, actSemanticIds, isMulti);
|
|
263
|
+
section.appendChild(form);
|
|
264
|
+
return section;
|
|
265
|
+
}
|
|
266
|
+
function buildForm(screenDef, surface, showSemanticIds, askSemanticIds, actSemanticIds, isMulti) {
|
|
267
|
+
const isMultiSurface = isMulti ?? screenDef.surfaces.length > 1;
|
|
268
|
+
const suffix = surfaceSuffix(surface.name, isMultiSurface);
|
|
181
269
|
const form = document.createElement("form");
|
|
182
270
|
form.setAttribute("method", "POST");
|
|
183
271
|
form.setAttribute("novalidate", "");
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
272
|
+
if (isMultiSurface) {
|
|
273
|
+
for (const item of surface.items) if ("state" in item) buildAskControl(form, screenDef, item, suffix, showSemanticIds, askSemanticIds);
|
|
274
|
+
else if ("handler" in item) buildActionButton(form, item, suffix, showSemanticIds, actSemanticIds);
|
|
275
|
+
} else {
|
|
276
|
+
const surfaceAskIds = /* @__PURE__ */ new Set();
|
|
277
|
+
const surfaceActIds = /* @__PURE__ */ new Set();
|
|
278
|
+
const orderedItems = [];
|
|
279
|
+
for (const item of surface.items) if ("state" in item) {
|
|
280
|
+
const ask = item;
|
|
281
|
+
surfaceAskIds.add(ask.id);
|
|
282
|
+
orderedItems.push({
|
|
283
|
+
kind: "ask",
|
|
284
|
+
node: ask
|
|
285
|
+
});
|
|
286
|
+
} else if ("handler" in item) {
|
|
287
|
+
const act = item;
|
|
288
|
+
surfaceActIds.add(act.id);
|
|
289
|
+
orderedItems.push({
|
|
290
|
+
kind: "act",
|
|
291
|
+
node: act
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
for (const ask of screenDef.asks) if (!surfaceAskIds.has(ask.id)) orderedItems.push({
|
|
295
|
+
kind: "ask",
|
|
296
|
+
node: ask
|
|
199
297
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
298
|
+
for (const act of screenDef.acts) if (!surfaceActIds.has(act.id)) orderedItems.push({
|
|
299
|
+
kind: "act",
|
|
300
|
+
node: act
|
|
301
|
+
});
|
|
302
|
+
for (const item of orderedItems) if (item.kind === "ask") buildAskControl(form, screenDef, item.node, suffix, showSemanticIds, askSemanticIds);
|
|
303
|
+
else buildActionButton(form, item.node, suffix, showSemanticIds, actSemanticIds);
|
|
304
|
+
}
|
|
305
|
+
const output = document.createElement("output");
|
|
306
|
+
output.id = `feedback-output${suffix}`;
|
|
307
|
+
output.setAttribute("aria-live", "polite");
|
|
308
|
+
form.appendChild(output);
|
|
309
|
+
return form;
|
|
310
|
+
}
|
|
311
|
+
function buildAskControl(form, screenDef, ask, suffix, showSemanticIds, askSemanticIds) {
|
|
312
|
+
const container = document.createElement("div");
|
|
313
|
+
container.className = "ask-group";
|
|
314
|
+
const label = document.createElement("label");
|
|
315
|
+
label.textContent = ask.label;
|
|
316
|
+
label.htmlFor = `${ask.id}${suffix}`;
|
|
317
|
+
if (showSemanticIds && askSemanticIds) {
|
|
318
|
+
const sid = askSemanticIds.get(ask.id);
|
|
319
|
+
if (sid) label.setAttribute("data-intent-ask", sid);
|
|
320
|
+
}
|
|
321
|
+
container.appendChild(label);
|
|
322
|
+
const control = createInputForAsk(ask);
|
|
323
|
+
control.id = `${ask.id}${suffix}`;
|
|
324
|
+
control.name = `${ask.id}${suffix}`;
|
|
325
|
+
if (showSemanticIds && askSemanticIds) {
|
|
326
|
+
const sid = askSemanticIds.get(ask.id);
|
|
327
|
+
if (sid) control.setAttribute("data-intent-ask", sid);
|
|
328
|
+
}
|
|
329
|
+
if (ask.required) control.required = true;
|
|
330
|
+
if (ask.kind === "contact" && ask.contactKind) control.setAttribute("autocomplete", ask.contactKind);
|
|
331
|
+
const stateObj = ask.state;
|
|
332
|
+
if (typeof ask.state.value === "boolean") {
|
|
333
|
+
control.checked = stateObj.value;
|
|
334
|
+
control.addEventListener("change", () => {
|
|
335
|
+
stateObj.set(control.checked);
|
|
336
|
+
});
|
|
337
|
+
} else if (ask.kind === "choice") {
|
|
338
|
+
const select = control;
|
|
339
|
+
const options = stateObj.options;
|
|
340
|
+
for (const opt of options) {
|
|
341
|
+
const option = document.createElement("option");
|
|
342
|
+
option.value = opt;
|
|
343
|
+
option.textContent = opt;
|
|
344
|
+
select.appendChild(option);
|
|
206
345
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
346
|
+
select.value = stateObj.value;
|
|
347
|
+
select.addEventListener("change", () => {
|
|
348
|
+
stateObj.set(select.value);
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
const textInput = control;
|
|
352
|
+
textInput.addEventListener("input", () => {
|
|
353
|
+
if (typeof stateObj.set === "function") stateObj.set(textInput.value);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
container.appendChild(control);
|
|
357
|
+
if (ask.hintText) {
|
|
358
|
+
const hint = document.createElement("p");
|
|
359
|
+
hint.id = `${ask.id}-hint${suffix}`;
|
|
360
|
+
hint.textContent = ask.hintText;
|
|
361
|
+
container.appendChild(hint);
|
|
362
|
+
}
|
|
363
|
+
const defaultAction = findDefaultAction(screenDef.acts);
|
|
364
|
+
if (defaultAction) {
|
|
365
|
+
const hintId = getEnterHintId(ask.id, suffix);
|
|
366
|
+
const hint = document.createElement("p");
|
|
367
|
+
hint.id = hintId;
|
|
368
|
+
hint.textContent = `Press Enter to ${sanitizeLabel(defaultAction.label)}.`;
|
|
369
|
+
if (!defaultAction.enabled.current) hint.style.display = "none";
|
|
370
|
+
container.appendChild(hint);
|
|
371
|
+
if (defaultAction.enabled.current) {
|
|
372
|
+
const existing = control.getAttribute("aria-describedby");
|
|
373
|
+
if (existing) control.setAttribute("aria-describedby", `${existing} ${hintId}`);
|
|
374
|
+
else control.setAttribute("aria-describedby", hintId);
|
|
220
375
|
}
|
|
221
|
-
form.appendChild(container);
|
|
222
376
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
377
|
+
form.appendChild(container);
|
|
378
|
+
}
|
|
379
|
+
function buildActionButton(form, act, suffix, showSemanticIds, actSemanticIds) {
|
|
380
|
+
const button = document.createElement("button");
|
|
381
|
+
button.id = `${act.id}${suffix}`;
|
|
382
|
+
button.type = "button";
|
|
383
|
+
button.textContent = act.label;
|
|
384
|
+
if (showSemanticIds && actSemanticIds) {
|
|
385
|
+
const sid = actSemanticIds.get(act.id);
|
|
386
|
+
if (sid) button.setAttribute("data-intent-action", sid);
|
|
387
|
+
}
|
|
388
|
+
if (act.primary) button.className = "primary";
|
|
389
|
+
if (!act.enabled.current) {
|
|
390
|
+
button.disabled = true;
|
|
391
|
+
if (act.blockedReasons.length > 0) {
|
|
392
|
+
const reasonId = getReasonId(act.id, suffix);
|
|
393
|
+
button.setAttribute("aria-describedby", reasonId);
|
|
394
|
+
const reasonEl = document.createElement("p");
|
|
395
|
+
reasonEl.id = reasonId;
|
|
396
|
+
reasonEl.className = "intent-blocked-reason";
|
|
397
|
+
reasonEl.setAttribute("role", "alert");
|
|
398
|
+
reasonEl.textContent = act.blockedReasons[0];
|
|
399
|
+
form.appendChild(reasonEl);
|
|
239
400
|
}
|
|
240
|
-
form.appendChild(button);
|
|
241
401
|
}
|
|
242
|
-
|
|
243
|
-
output.id = "feedback-output";
|
|
244
|
-
output.setAttribute("aria-live", "polite");
|
|
245
|
-
form.appendChild(output);
|
|
246
|
-
main.appendChild(form);
|
|
247
|
-
return main;
|
|
402
|
+
form.appendChild(button);
|
|
248
403
|
}
|
|
249
404
|
function updateFeedback(act, output) {
|
|
250
405
|
const msg = act.feedback && act.statusMessage ? act.statusMessage : "";
|
|
@@ -252,13 +407,16 @@ function updateFeedback(act, output) {
|
|
|
252
407
|
else output.textContent = "";
|
|
253
408
|
}
|
|
254
409
|
function createInputForAsk(ask) {
|
|
410
|
+
if (typeof ask.state.value === "boolean") {
|
|
411
|
+
const input$1 = document.createElement("input");
|
|
412
|
+
input$1.type = "checkbox";
|
|
413
|
+
return input$1;
|
|
414
|
+
}
|
|
415
|
+
if (ask.kind === "choice") return document.createElement("select");
|
|
255
416
|
const input = document.createElement("input");
|
|
256
417
|
if (ask.kind === "contact" && ask.contactKind === "email") input.type = "email";
|
|
257
418
|
else if (ask.kind === "secret") input.type = "password";
|
|
258
|
-
else
|
|
259
|
-
input.type = "text";
|
|
260
|
-
input.setAttribute("role", "combobox");
|
|
261
|
-
} else input.type = "text";
|
|
419
|
+
else input.type = "text";
|
|
262
420
|
return input;
|
|
263
421
|
}
|
|
264
422
|
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.1.0-alpha.
|
|
6
|
+
"version": "0.1.0-alpha.10",
|
|
7
7
|
"description": "DOM materializer for Intent screens and router",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"dist"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@intent-framework/core": "0.1.0-alpha.
|
|
30
|
-
"@intent-framework/router": "0.1.0-alpha.
|
|
29
|
+
"@intent-framework/core": "^0.1.0-alpha.10",
|
|
30
|
+
"@intent-framework/router": "^0.1.0-alpha.10"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"jsdom": "^29.1.1",
|