Skip to content

Commit ce52d3e

Browse files
authored
fix(dom): render boolean asks as checkbox, choice asks as select, add blocked reason class/role (#109)
Boolean-backed asks render as checkbox inputs Choice asks render as select elements Blocked reason elements get class="intent-blocked-reason" and role="alert" Enter default action skips checkbox/select controls choice-form example gets visible blocked reason styling
1 parent 812cf26 commit ce52d3e

4 files changed

Lines changed: 244 additions & 20 deletions

File tree

.changeset/sweet-bananas-fix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@intent-framework/dom": patch
3+
---
4+
5+
DOM rendering now uses proper controls for boolean and choice asks: boolean-backed asks render as checkboxes, choice asks render as select elements. Blocked reason elements now include an `intent-blocked-reason` class and `role="alert"` for accessible error feedback. Enter key default action is disabled for non-text inputs (checkbox, select).

examples/choice-form/index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,16 @@
99
h1:first-of-type { font-size: 1.25rem; }
1010
h2 { font-size: 1rem; margin-top: 2rem; }
1111
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; }
12+
input[type=text], input[type=email], input[type=password] { width: 100%; box-sizing: border-box; padding: 0.5rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; }
13+
select { width: 100%; box-sizing: border-box; padding: 0.5rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; background: #fff; }
14+
input[type=checkbox] { width: auto; margin-top: 0.5rem; }
15+
.ask-group:has(input[type=checkbox]) label { display: inline; margin-left: 0.5rem; }
16+
.ask-group:has(input[type=checkbox]) { display: flex; flex-direction: row-reverse; justify-content: flex-end; align-items: center; gap: 0.5rem; }
1317
button { margin-top: 0.75rem; padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; border: 1px solid #aaa; border-radius: 4px; background: #fff; }
1418
button.primary { font-weight: 700; background: #1a73e8; color: #fff; border-color: #1a73e8; }
1519
button:disabled { opacity: 0.5; cursor: not-allowed; }
1620
output { display: block; margin-top: 0.5rem; font-style: italic; color: #555; }
21+
.intent-blocked-reason { color: #c62828; background: #ffebee; padding: 0.5rem; border-radius: 4px; font-weight: 600; font-size: 0.875rem; margin-top: 0.25rem; }
1722
pre { background: #f5f5f5; padding: 0.75rem; border-radius: 4px; font-size: 0.8rem; overflow-x: auto; }
1823
.proves { background: #e8f0fe; padding: 0.75rem; border-radius: 4px; margin-top: 1.5rem; }
1924
.proves ul { margin: 0.25rem 0; padding-left: 1.25rem; }

packages/dom/src/dom.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,4 +1598,180 @@ describe("DOM renderer", () => {
15981598
expect(headingsAfter[0]!.textContent).toBe("MyScreen")
15991599
})
16001600
})
1601+
1602+
describe("control rendering for boolean and choice asks", () => {
1603+
it("boolean-backed ask renders as checkbox input", () => {
1604+
document.body.innerHTML = '<div id="root"></div>'
1605+
1606+
const Screen = screen("BoolCheckbox", $ => {
1607+
const accepted = $.state.boolean("acceptedTerms")
1608+
const termsAsk = $.ask("Accept terms", accepted)
1609+
$.surface("main").contains(termsAsk)
1610+
})
1611+
1612+
const root = document.getElementById("root")!
1613+
renderDom(Screen, { target: root })
1614+
1615+
const checkbox = root.querySelector("input[type=checkbox]")
1616+
expect(checkbox).not.toBeNull()
1617+
expect(checkbox!.id).toBe("ask_accept_terms")
1618+
expect((checkbox as HTMLInputElement).checked).toBe(false)
1619+
})
1620+
1621+
it("checkbox change updates boolean state", () => {
1622+
document.body.innerHTML = '<div id="root"></div>'
1623+
1624+
const Screen = screen("BoolCheckboxUpdate", $ => {
1625+
const accepted = $.state.boolean("acceptedTerms")
1626+
const termsAsk = $.ask("Accept terms", accepted)
1627+
$.surface("main").contains(termsAsk)
1628+
})
1629+
1630+
const root = document.getElementById("root")!
1631+
renderDom(Screen, { target: root })
1632+
1633+
const checkbox = root.querySelector("input[type=checkbox]") as HTMLInputElement
1634+
expect(checkbox.checked).toBe(false)
1635+
1636+
checkbox.checked = true
1637+
checkbox.dispatchEvent(new Event("change", { bubbles: true }))
1638+
1639+
const state = Screen.asks[0]!.state as unknown as { value: boolean }
1640+
expect(state.value).toBe(true)
1641+
1642+
checkbox.checked = false
1643+
checkbox.dispatchEvent(new Event("change", { bubbles: true }))
1644+
expect(state.value).toBe(false)
1645+
})
1646+
1647+
it("choice ask renders as select element with options", () => {
1648+
document.body.innerHTML = '<div id="root"></div>'
1649+
1650+
const Screen = screen("ChoiceSelect", $ => {
1651+
const role = $.state.choice("role", {
1652+
initial: "member",
1653+
options: ["admin", "member", "viewer"] as const,
1654+
})
1655+
const roleAsk = $.ask("Role", role).asChoice()
1656+
$.surface("main").contains(roleAsk)
1657+
})
1658+
1659+
const root = document.getElementById("root")!
1660+
renderDom(Screen, { target: root })
1661+
1662+
const select = root.querySelector("select")
1663+
expect(select).not.toBeNull()
1664+
expect(select!.id).toBe("ask_role")
1665+
1666+
const options = select!.querySelectorAll("option")
1667+
expect(options).toHaveLength(3)
1668+
expect(options[0]!.value).toBe("admin")
1669+
expect(options[1]!.value).toBe("member")
1670+
expect(options[2]!.value).toBe("viewer")
1671+
expect(select!.value).toBe("member")
1672+
})
1673+
1674+
it("select change updates choice state", () => {
1675+
document.body.innerHTML = '<div id="root"></div>'
1676+
1677+
const Screen = screen("ChoiceSelectUpdate", $ => {
1678+
const role = $.state.choice("role", {
1679+
initial: "member",
1680+
options: ["admin", "member", "viewer"] as const,
1681+
})
1682+
const roleAsk = $.ask("Role", role).asChoice()
1683+
$.surface("main").contains(roleAsk)
1684+
})
1685+
1686+
const root = document.getElementById("root")!
1687+
renderDom(Screen, { target: root })
1688+
1689+
const select = root.querySelector("select") as HTMLSelectElement
1690+
select.value = "admin"
1691+
select.dispatchEvent(new Event("change", { bubbles: true }))
1692+
1693+
const state = Screen.asks[0]!.state as unknown as { value: string }
1694+
expect(state.value).toBe("admin")
1695+
1696+
select.value = "viewer"
1697+
select.dispatchEvent(new Event("change", { bubbles: true }))
1698+
expect(state.value).toBe("viewer")
1699+
})
1700+
1701+
it("contact/secret/text inputs retain existing behavior", () => {
1702+
document.body.innerHTML = '<div id="root"></div>'
1703+
1704+
const Screen = screen("ExistingBehavior", $ => {
1705+
const email = $.state.text("email")
1706+
const password = $.state.text("password")
1707+
const name = $.state.text("name")
1708+
const emailAsk = $.ask("Email", email).asContact("email").required()
1709+
const passwordAsk = $.ask("Password", password).asSecret().required()
1710+
const nameAsk = $.ask("Name", name).required()
1711+
$.surface("main").contains(emailAsk, passwordAsk, nameAsk)
1712+
})
1713+
1714+
const root = document.getElementById("root")!
1715+
renderDom(Screen, { target: root })
1716+
1717+
const inputs = root.querySelectorAll("input")
1718+
expect(inputs).toHaveLength(3)
1719+
expect(inputs[0]!.getAttribute("type")).toBe("email")
1720+
expect(inputs[1]!.getAttribute("type")).toBe("password")
1721+
expect(inputs[2]!.getAttribute("type")).toBe("text")
1722+
})
1723+
1724+
it("blocked reason has intent-blocked-reason class and role alert", () => {
1725+
document.body.innerHTML = '<div id="root"></div>'
1726+
1727+
const Screen = screen("BlockedReasonAccessible", $ => {
1728+
const email = $.state.text("email")
1729+
const emailAsk = $.ask("Email", email).required()
1730+
const login = $.act("Log in").primary().when(emailAsk.valid, "Enter your email.")
1731+
$.surface("main").contains(emailAsk, login)
1732+
})
1733+
1734+
const root = document.getElementById("root")!
1735+
renderDom(Screen, { target: root })
1736+
1737+
const reasonEl = document.querySelector(".intent-blocked-reason")
1738+
expect(reasonEl).not.toBeNull()
1739+
expect(reasonEl!.getAttribute("role")).toBe("alert")
1740+
expect(reasonEl!.textContent).toBe("Enter your email.")
1741+
})
1742+
1743+
it("button enables after filling text fields and checking checkbox", () => {
1744+
document.body.innerHTML = '<div id="root"></div>'
1745+
1746+
const Screen = screen("ButtonEnablesCheckbox", $ => {
1747+
const name = $.state.text("name")
1748+
const accepted = $.state.boolean("acceptedTerms")
1749+
const nameAsk = $.ask("Name", name).required()
1750+
const termsAsk = $.ask("Accept terms", accepted)
1751+
.validate(v => v === true ? true : "Accept the terms to continue")
1752+
const submit = $.act("Submit").primary()
1753+
.when(nameAsk.valid, "Enter your name")
1754+
.when(termsAsk.valid, "Accept the terms to continue")
1755+
$.surface("main").contains(nameAsk, termsAsk, submit)
1756+
})
1757+
1758+
const root = document.getElementById("root")!
1759+
renderDom(Screen, { target: root })
1760+
1761+
const button = root.querySelector("button") as HTMLButtonElement
1762+
expect(button.disabled).toBe(true)
1763+
1764+
// Fill in text field — still blocked because checkbox not checked
1765+
const nameInput = root.querySelector("input[type=text]") as HTMLInputElement
1766+
nameInput.value = "Ada"
1767+
nameInput.dispatchEvent(new Event("input", { bubbles: true }))
1768+
expect(button.disabled).toBe(true)
1769+
1770+
// Check the checkbox — now enabled
1771+
const checkbox = root.querySelector("input[type=checkbox]") as HTMLInputElement
1772+
checkbox.checked = true
1773+
checkbox.dispatchEvent(new Event("change", { bubbles: true }))
1774+
expect(button.disabled).toBe(false)
1775+
})
1776+
})
16011777
})

packages/dom/src/index.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export function renderDom<TServices extends object = DefaultScreenServices>(
6666
if (!reasonEl) {
6767
reasonEl = document.createElement("p")
6868
reasonEl.id = reasonId
69+
reasonEl.className = "intent-blocked-reason"
70+
reasonEl.setAttribute("role", "alert")
6971
form.appendChild(reasonEl)
7072
}
7173
reasonEl.textContent = act.blockedReasons[0]!
@@ -142,6 +144,8 @@ export function renderDom<TServices extends object = DefaultScreenServices>(
142144
if (event.key !== "Enter") return
143145
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return
144146
if (input.tagName === "TEXTAREA") return
147+
if (input.tagName === "SELECT") return
148+
if ((input as HTMLInputElement).type === "checkbox") return
145149

146150
const defaultAction = findDefaultAction(screenDef.acts)
147151
if (!defaultAction || !defaultAction.enabled.current) return
@@ -214,32 +218,54 @@ function buildDom<TServices extends object = DefaultScreenServices>(
214218
}
215219
container.appendChild(label)
216220

217-
const input = createInputForAsk(ask)
218-
input.id = ask.id
219-
input.name = ask.id
221+
const control = createInputForAsk(ask)
222+
control.id = ask.id
223+
;(control as HTMLInputElement).name = ask.id
220224
if (showSemanticIds && askSemanticIds) {
221225
const sid = askSemanticIds.get(ask.id)
222226
if (sid) {
223-
input.setAttribute("data-intent-ask", sid)
227+
control.setAttribute("data-intent-ask", sid)
224228
}
225229
}
226230

227231
if (ask.required) {
228-
input.required = true
232+
(control as HTMLInputElement).required = true
229233
}
230234

231235
if (ask.kind === "contact" && ask.contactKind) {
232-
input.setAttribute("autocomplete", ask.contactKind)
236+
control.setAttribute("autocomplete", ask.contactKind)
233237
}
234238

235-
input.addEventListener("input", () => {
236-
const stateObj = ask.state as unknown as { value: string; set: (v: string) => void }
237-
if (typeof stateObj.set === "function") {
238-
stateObj.set(input.value)
239+
if (typeof ask.state.value === "boolean") {
240+
const stateObj = ask.state as unknown as { value: boolean; set: (v: boolean) => void }
241+
;(control as HTMLInputElement).checked = stateObj.value
242+
control.addEventListener("change", () => {
243+
stateObj.set((control as HTMLInputElement).checked)
244+
})
245+
} else if (ask.kind === "choice") {
246+
const stateObj = ask.state as unknown as { value: string; set: (v: string) => void; options: readonly string[] }
247+
const select = control as HTMLSelectElement
248+
for (const opt of stateObj.options) {
249+
const option = document.createElement("option")
250+
option.value = opt
251+
option.textContent = opt
252+
select.appendChild(option)
239253
}
240-
})
254+
select.value = stateObj.value
255+
select.addEventListener("change", () => {
256+
stateObj.set(select.value)
257+
})
258+
} else {
259+
const textInput = control as HTMLInputElement
260+
textInput.addEventListener("input", () => {
261+
const stateObj = ask.state as unknown as { value: string; set: (v: string) => void }
262+
if (typeof stateObj.set === "function") {
263+
stateObj.set(textInput.value)
264+
}
265+
})
266+
}
241267

242-
container.appendChild(input)
268+
container.appendChild(control)
243269

244270
if (ask.hintText) {
245271
const hint = document.createElement("p")
@@ -260,11 +286,11 @@ function buildDom<TServices extends object = DefaultScreenServices>(
260286
container.appendChild(hint)
261287

262288
if (defaultAction.enabled.current) {
263-
const existing = input.getAttribute("aria-describedby")
289+
const existing = control.getAttribute("aria-describedby")
264290
if (existing) {
265-
input.setAttribute("aria-describedby", `${existing} ${hintId}`)
291+
control.setAttribute("aria-describedby", `${existing} ${hintId}`)
266292
} else {
267-
input.setAttribute("aria-describedby", hintId)
293+
control.setAttribute("aria-describedby", hintId)
268294
}
269295
}
270296
}
@@ -295,6 +321,8 @@ function buildDom<TServices extends object = DefaultScreenServices>(
295321
button.setAttribute("aria-describedby", reasonId)
296322
const reasonEl = document.createElement("p")
297323
reasonEl.id = reasonId
324+
reasonEl.className = "intent-blocked-reason"
325+
reasonEl.setAttribute("role", "alert")
298326
reasonEl.textContent = act.blockedReasons[0]!
299327
form.appendChild(reasonEl)
300328
}
@@ -322,16 +350,26 @@ function updateFeedback<TServices extends object = DefaultScreenServices>(act: A
322350
}
323351
}
324352

325-
function createInputForAsk(ask: { kind: string; contactKind?: string }): HTMLInputElement {
353+
function createInputForAsk(ask: { kind: string; contactKind?: string; state: { value: unknown } }): HTMLElement {
354+
// Boolean-backed asks render as checkbox
355+
if (typeof ask.state.value === "boolean") {
356+
const input = document.createElement("input")
357+
input.type = "checkbox"
358+
return input
359+
}
360+
361+
// Choice asks render as select
362+
if (ask.kind === "choice") {
363+
return document.createElement("select")
364+
}
365+
366+
// Text / contact / secret → text-like inputs
326367
const input = document.createElement("input")
327368

328369
if (ask.kind === "contact" && ask.contactKind === "email") {
329370
input.type = "email"
330371
} else if (ask.kind === "secret") {
331372
input.type = "password"
332-
} else if (ask.kind === "choice") {
333-
input.type = "text"
334-
input.setAttribute("role", "combobox")
335373
} else {
336374
input.type = "text"
337375
}

0 commit comments

Comments
 (0)