Skip to content

Commit 1b3872a

Browse files
committed
feat: add choice-form example with all state kinds and multi-surface
1 parent 2cb90b6 commit 1b3872a

10 files changed

Lines changed: 304 additions & 1 deletion

File tree

examples/choice-form/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Choice Form Example
2+
3+
A registration form demonstrating every state kind and ask kind in Intent:
4+
5+
- `TextState` for name, email, and password fields
6+
- `ChoiceState` for role selection (admin, member, viewer)
7+
- `BooleanState` for terms acceptance
8+
- `ask.asChoice()`, `ask.asSecret()`, `ask.hint()`
9+
- Multiple surfaces (`main` + `sidebar`)
10+
- Full `@intent-framework/testing` coverage
11+
12+
## Run
13+
14+
```sh
15+
pnpm install
16+
pnpm dev:choice-form
17+
```
18+
19+
Open the local URL printed by Vite.
20+
21+
## Test
22+
23+
```sh
24+
pnpm test
25+
```
26+
27+
## What it proves
28+
29+
- All state kinds (`TextState`, `ChoiceState`, `BooleanState`) work end-to-end
30+
- `asChoice()` renders choice input, `asSecret()` masks input, `hint()` provides guidance
31+
- Multiple surfaces per screen are defined and inspected correctly
32+
- Flow diagnostics (`flow-step-not-surfaced`, `orphaned-flow`) are absent for a correct configuration
33+
- DOM build and test paths work without manual browser steps

examples/choice-form/index.html

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Intent — Choice Form Example</title>
7+
<style>
8+
body { font-family: system-ui, sans-serif; max-width: 650px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
9+
h1:first-of-type { font-size: 1.25rem; }
10+
h2 { font-size: 1rem; margin-top: 2rem; }
11+
label { display: block; font-weight: 600; margin-top: 1rem; margin-bottom: 0.25rem; }
12+
input { width: 100%; box-sizing: border-box; padding: 0.5rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; }
13+
button { margin-top: 0.75rem; padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; border: 1px solid #aaa; border-radius: 4px; background: #fff; }
14+
button.primary { font-weight: 700; background: #1a73e8; color: #fff; border-color: #1a73e8; }
15+
button:disabled { opacity: 0.5; cursor: not-allowed; }
16+
output { display: block; margin-top: 0.5rem; font-style: italic; color: #555; }
17+
pre { background: #f5f5f5; padding: 0.75rem; border-radius: 4px; font-size: 0.8rem; overflow-x: auto; }
18+
.proves { background: #e8f0fe; padding: 0.75rem; border-radius: 4px; margin-top: 1.5rem; }
19+
.proves ul { margin: 0.25rem 0; padding-left: 1.25rem; }
20+
</style>
21+
</head>
22+
<body>
23+
<h1>Intent: Choice Form</h1>
24+
<p>A registration form demonstrating all state kinds and ask kinds.</p>
25+
<div id="root"></div>
26+
<div class="proves">
27+
<strong>What this proves:</strong>
28+
<ul>
29+
<li><code>TextState</code> for free-text fields (name, email, password).</li>
30+
<li><code>ChoiceState</code> for role selection (admin, member, viewer).</li>
31+
<li><code>BooleanState</code> for terms acceptance.</li>
32+
<li><code>ask.asChoice()</code>, <code>ask.asSecret()</code>, <code>ask.hint()</code>.</li>
33+
<li>Multiple surfaces per screen (<code>main</code> + <code>sidebar</code>).</li>
34+
<li>Feedback on form submission.</li>
35+
</ul>
36+
</div>
37+
<h2>Semantic Graph (<code>inspectScreen()</code>)</h2>
38+
<pre id="inspect">Loading...</pre>
39+
<script type="module" src="/src/main.ts"></script>
40+
</body>
41+
</html>

examples/choice-form/package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "choice-form",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"test": "vitest run",
10+
"typecheck": "tsc --noEmit",
11+
"lint": "tsc --noEmit"
12+
},
13+
"dependencies": {
14+
"@intent-framework/core": "workspace:*",
15+
"@intent-framework/dom": "workspace:*"
16+
},
17+
"devDependencies": {
18+
"@intent-framework/testing": "workspace:*",
19+
"typescript": "^5.7.0",
20+
"vite": "^6.0.0",
21+
"vitest": "^3.1.1"
22+
}
23+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, it, expect } from "vitest"
2+
import { testScreen } from "@intent-framework/testing"
3+
import { inspectScreen } from "@intent-framework/core"
4+
import { RegistrationForm } from "./RegistrationForm.js"
5+
6+
describe("RegistrationForm", () => {
7+
it("text state can be answered through the testing harness", async () => {
8+
await testScreen(RegistrationForm, async app => {
9+
await app.answer("Name", "Ada Lovelace")
10+
await app.answer("Email", "ada@example.com")
11+
app.act("Register").toBeBlockedBy("Enter a valid password")
12+
})
13+
})
14+
15+
it("choice state can be answered and read", async () => {
16+
await testScreen(RegistrationForm, async app => {
17+
await app.answer("Role", "admin")
18+
const roleAsk = app.state().asks.find(a => a.label === "Role")!
19+
const value = (roleAsk.state as { value: string }).value
20+
expect(value).toBe("admin")
21+
})
22+
})
23+
24+
it("boolean state can be toggled", async () => {
25+
await testScreen(RegistrationForm, async app => {
26+
const termsNode = app.state().asks.find(a => a.label === "Accept terms")!
27+
const state = termsNode.state as unknown as { value: boolean; toggle: () => void }
28+
expect(state.value).toBe(false)
29+
state.toggle()
30+
expect(state.value).toBe(true)
31+
})
32+
})
33+
34+
it("secret ask participates in validity", async () => {
35+
await testScreen(RegistrationForm, async app => {
36+
await app.answer("Password", "short")
37+
app.act("Register").toBeBlocked()
38+
const passAsk = app.state().asks.find(a => a.label === "Password")!
39+
expect(passAsk.error).toBe("Password must be at least 8 characters")
40+
})
41+
})
42+
43+
it("submit is blocked before required inputs are valid", async () => {
44+
await testScreen(RegistrationForm, async app => {
45+
app.act("Register").toBeBlocked()
46+
})
47+
})
48+
49+
it("submit becomes enabled after valid inputs and accepted terms", async () => {
50+
await testScreen(RegistrationForm, async app => {
51+
await app.answer("Name", "Ada Lovelace")
52+
await app.answer("Email", "ada@example.com")
53+
await app.answer("Password", "securepass123")
54+
await app.answer("Role", "admin")
55+
56+
const termsNode = app.state().asks.find(a => a.label === "Accept terms")!
57+
const termsState = termsNode.state as unknown as { set: (v: boolean) => void; value: boolean }
58+
termsState.set(false)
59+
60+
app.act("Register").toBeBlockedBy("Accept the terms to continue")
61+
62+
termsState.set(true)
63+
64+
app.act("Register").toBeEnabled()
65+
66+
await app.act("Register").run()
67+
expect(app.feedback()).toBe("Registration complete!")
68+
})
69+
})
70+
71+
it("inspectScreen reports multiple surfaces", () => {
72+
const graph = inspectScreen(RegistrationForm)
73+
expect(graph.surfaces).toHaveLength(2)
74+
expect(graph.surfaces[0]!.name).toBe("main")
75+
expect(graph.surfaces[1]!.name).toBe("sidebar")
76+
})
77+
78+
it("inspectScreen reports no flow diagnostics for this correct example", () => {
79+
const graph = inspectScreen(RegistrationForm)
80+
const flowWarnings = graph.diagnostics.filter(
81+
d => d.code === "flow-step-not-surfaced" || d.code === "orphaned-flow",
82+
)
83+
expect(flowWarnings).toHaveLength(0)
84+
})
85+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { screen } from "@intent-framework/core"
2+
3+
export const RegistrationForm = screen("RegistrationForm", $ => {
4+
const name = $.state.text("name")
5+
const email = $.state.text("email")
6+
const password = $.state.text("password")
7+
const role = $.state.choice("role", {
8+
initial: "member",
9+
options: ["admin", "member", "viewer"] as const,
10+
})
11+
const acceptedTerms = $.state.boolean("acceptedTerms")
12+
13+
const nameAsk = $.ask("Name", name)
14+
.required("Name is required")
15+
.hint("Enter your full name")
16+
17+
const emailAsk = $.ask("Email", email)
18+
.asContact("email")
19+
.required("Email is required")
20+
.private()
21+
.validate(v => (v.includes("@") ? true : "Enter a valid email"))
22+
.hint("We'll use this for account recovery")
23+
24+
const passwordAsk = $.ask("Password", password)
25+
.asSecret()
26+
.required("Password is required")
27+
.private()
28+
.validate(v => (v.length >= 8 ? true : "Password must be at least 8 characters"))
29+
30+
const roleAsk = $.ask("Role", role)
31+
.asChoice()
32+
.required("Select a role")
33+
.hint("Choose admin, member, or viewer")
34+
35+
const termsAsk = $.ask("Accept terms", acceptedTerms)
36+
37+
const submit = $.act("Register")
38+
.primary()
39+
.when(nameAsk.valid, "Enter your name")
40+
.when(emailAsk.valid, "Enter a valid email")
41+
.when(passwordAsk.valid, "Enter a valid password")
42+
.when(roleAsk.valid, "Select a role")
43+
.when(() => acceptedTerms.value, "Accept the terms to continue")
44+
.does(() => {
45+
console.log("Registered:", {
46+
name: name.value,
47+
email: email.value,
48+
role: role.value,
49+
})
50+
})
51+
.feedback({
52+
pending: "Registering...",
53+
success: "Registration complete!",
54+
failure: "Registration failed.",
55+
})
56+
57+
$.flow("registration")
58+
.startsWith(nameAsk)
59+
.then(emailAsk)
60+
.then(passwordAsk)
61+
.then(roleAsk)
62+
.then(termsAsk)
63+
.then(submit)
64+
65+
$.surface("main").contains(nameAsk, emailAsk, passwordAsk, roleAsk, termsAsk, submit)
66+
$.surface("sidebar").contains(roleAsk, termsAsk)
67+
})

examples/choice-form/src/main.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { renderDom } from "@intent-framework/dom"
2+
import { inspectScreen } from "@intent-framework/core"
3+
import { RegistrationForm } from "./RegistrationForm.js"
4+
5+
const root = document.getElementById("root")!
6+
renderDom(RegistrationForm, { target: root, showScreenName: true })
7+
8+
const inspect = document.getElementById("inspect")!
9+
const graph = inspectScreen(RegistrationForm)
10+
inspect.textContent = JSON.stringify(graph, null, 2)

examples/choice-form/tsconfig.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"lib": ["ES2022", "DOM"],
5+
"noUnusedLocals": false,
6+
"noUnusedParameters": false
7+
},
8+
"include": ["src"]
9+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from "vite"
2+
3+
export default defineConfig({
4+
resolve: {
5+
alias: {
6+
"@intent-framework/core": new URL("../../packages/core/src/index.ts", import.meta.url).pathname,
7+
"@intent-framework/dom": new URL("../../packages/dom/src/index.ts", import.meta.url).pathname,
8+
"@intent-framework/testing": new URL("../../packages/testing/src/index.ts", import.meta.url).pathname,
9+
},
10+
conditions: ["development", "browser"],
11+
},
12+
})

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"release:alpha": "pnpm build && pnpm pack:check && changeset publish",
1212
"pack:check": "pnpm -r --filter @intent-framework/core --filter @intent-framework/dom --filter @intent-framework/router --filter @intent-framework/testing exec npm pack --dry-run",
1313
"dev:web-basic": "pnpm --dir examples/web-basic dev",
14-
"dev:canonical": "pnpm --dir examples/canonical-invite dev"
14+
"dev:canonical": "pnpm --dir examples/canonical-invite dev",
15+
"dev:choice-form": "pnpm --dir examples/choice-form dev"
1516
},
1617
"engines": {
1718
"node": ">=18"

pnpm-lock.yaml

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)