From 574aa9be6e8022eb387427b08ad3e82443431110 Mon Sep 17 00:00:00 2001 From: Ben Jacobson Date: Tue, 20 Oct 2026 22:14:04 +0000 Subject: [PATCH] Add mobile inputs demo with conditional fields and a mobile benchmark Adds a mobile-first candidate application at /mobile-form built from stateless touch-tuned controls: correct keyboards and autofill tokens, caret-preserving masks, a searchable bottom-sheet state picker, a one-time-code input, and yes/no questions that reveal nested follow-ups with a CSS-only animation. A standalone build (apply.html) and a Playwright benchmark measure payload and interaction latency on an emulated mid-range phone; docs/mobile-inputs.md records the method and results against a React baseline. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + apps/demo/apply.html | 25 + apps/demo/bench/mobile-form.mjs | 281 +++++++++ apps/demo/e2e/mobile-form.scenarios.ts | 109 ++++ apps/demo/e2e/workflow.e2e.test.ts | 3 + apps/demo/package.json | 3 +- apps/demo/src/app/message.ts | 2 + apps/demo/src/app/model.ts | 3 + apps/demo/src/app/route.test.ts | 3 + apps/demo/src/app/route.ts | 10 + apps/demo/src/app/update.ts | 30 +- apps/demo/src/app/view.ts | 71 ++- apps/demo/src/entry.ts | 9 +- apps/demo/src/mobile-form/form-view.ts | 666 +++++++++++++++++++++ apps/demo/src/mobile-form/format.test.ts | 65 ++ apps/demo/src/mobile-form/format.ts | 119 ++++ apps/demo/src/mobile-form/inputs.styles.ts | 447 ++++++++++++++ apps/demo/src/mobile-form/inputs.ts | 657 ++++++++++++++++++++ apps/demo/src/mobile-form/message.ts | 32 + apps/demo/src/mobile-form/model.ts | 108 ++++ apps/demo/src/mobile-form/performance.ts | 96 +++ apps/demo/src/mobile-form/standalone.css | 21 + apps/demo/src/mobile-form/standalone.ts | 27 + apps/demo/src/mobile-form/states.ts | 73 +++ apps/demo/src/mobile-form/styles.ts | 295 +++++++++ apps/demo/src/mobile-form/update.test.ts | 158 +++++ apps/demo/src/mobile-form/update.ts | 257 ++++++++ apps/demo/src/mobile-form/validation.ts | 128 ++++ apps/demo/src/mobile-form/view.ts | 78 +++ apps/demo/vite.mobile-form.config.ts | 21 + docs/mobile-inputs.md | 121 ++++ 31 files changed, 3878 insertions(+), 41 deletions(-) create mode 100644 apps/demo/apply.html create mode 100644 apps/demo/bench/mobile-form.mjs create mode 100644 apps/demo/e2e/mobile-form.scenarios.ts create mode 100644 apps/demo/src/mobile-form/form-view.ts create mode 100644 apps/demo/src/mobile-form/format.test.ts create mode 100644 apps/demo/src/mobile-form/format.ts create mode 100644 apps/demo/src/mobile-form/inputs.styles.ts create mode 100644 apps/demo/src/mobile-form/inputs.ts create mode 100644 apps/demo/src/mobile-form/message.ts create mode 100644 apps/demo/src/mobile-form/model.ts create mode 100644 apps/demo/src/mobile-form/performance.ts create mode 100644 apps/demo/src/mobile-form/standalone.css create mode 100644 apps/demo/src/mobile-form/standalone.ts create mode 100644 apps/demo/src/mobile-form/states.ts create mode 100644 apps/demo/src/mobile-form/styles.ts create mode 100644 apps/demo/src/mobile-form/update.test.ts create mode 100644 apps/demo/src/mobile-form/update.ts create mode 100644 apps/demo/src/mobile-form/validation.ts create mode 100644 apps/demo/src/mobile-form/view.ts create mode 100644 apps/demo/vite.mobile-form.config.ts create mode 100644 docs/mobile-inputs.md diff --git a/.gitignore b/.gitignore index c8fd9e6..acb72cb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ test-results/ playwright-report/ .DS_Store /PLAN.md +.bench/ diff --git a/apps/demo/apply.html b/apps/demo/apply.html new file mode 100644 index 0000000..c0800a4 --- /dev/null +++ b/apps/demo/apply.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + Apply · Foldworks + + +
+ + + diff --git a/apps/demo/bench/mobile-form.mjs b/apps/demo/bench/mobile-form.mjs new file mode 100644 index 0000000..f3dd8c4 --- /dev/null +++ b/apps/demo/bench/mobile-form.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +/** + * Benchmarks the mobile candidate form on an emulated mid-range phone. + * + * node bench/mobile-form.mjs [--runs 5] [--cpu 4] label=path/to/entry.html [label=http://…] + * + * Each target must implement the form described in `src/mobile-form` with the + * same element ids and button text, so one scripted session drives any + * implementation. Files are served from their directory with Brotli, the page + * loads over emulated Slow 4G, and the whole session runs with the CPU slowed + * by `--cpu` (Lighthouse's mobile default is 4x). + * + * The session covers every step: text, masks, the state sheet, choice controls, + * conditional follow-up questions that animate open, and the one-time code. + * + * Reported per target (median across runs): + * - transfer: compressed bytes for the first load + * - FCP and form-ready: first paint, and the moment `#first-name` exists + * - key → paint: keydown to the next painted frame, for every keystroke + * - tap → paint: click to the next painted frame, for every tap + * - INP: the slowest interaction (Event Timing, grouped by interactionId), and how + * many interactions took 50ms or more + * - script: main-thread JavaScript time for the entire scripted session + */ +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { readFile, stat } from "node:fs/promises"; +import { brotliCompressSync, constants } from "node:zlib"; +import { dirname, extname, join, resolve, basename } from "node:path"; +import { chromium, devices } from "playwright"; + +const args = process.argv.slice(2); +const option = (name, fallback) => { + const index = args.indexOf(`--${name}`); + return index === -1 ? fallback : Number(args.splice(index, 2)[1]); +}; +const runs = option("runs", 5); +const cpu = option("cpu", 4); +const targets = args.map((arg) => { + const at = arg.indexOf("="); + if (at === -1) throw new Error(`Expected label=path-or-url, got ${arg}`); + return { label: arg.slice(0, at), location: arg.slice(at + 1) }; +}); +if (targets.length === 0) { + console.error("Usage: node bench/mobile-form.mjs [--runs 5] [--cpu 4] label=entry.html …"); + process.exit(1); +} + +const types = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", +}; + +/** Serves a build directory with Brotli, like a CDN would. */ +const serve = async (entry) => { + const root = dirname(resolve(entry)); + const cache = new Map(); + const server = createServer(async (request, response) => { + const path = new URL(request.url ?? "/", "http://x").pathname; + const file = join(root, path === "/" ? basename(entry) : path); + try { + if (!(await stat(file)).isFile()) throw new Error("not a file"); + let body = cache.get(file); + if (body === undefined) { + body = brotliCompressSync(await readFile(file), { + params: { [constants.BROTLI_PARAM_QUALITY]: 11 }, + }); + cache.set(file, body); + } + response.writeHead(200, { + "content-type": types[extname(file)] ?? "application/octet-stream", + "content-encoding": "br", + "cache-control": "no-store", + }); + response.end(body); + } catch { + response.writeHead(404).end(); + } + }); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const { port } = server.address(); + return { url: `http://127.0.0.1:${port}/${basename(entry)}`, close: () => server.close() }; +}; + +/** Framework-neutral probes, installed before any application code runs. */ +const probe = () => { + const bench = { keys: [], taps: [], interactions: {}, ready: 0 }; + window.__bench = bench; + const toPaint = (list) => (event) => { + const start = event.timeStamp; + requestAnimationFrame(() => setTimeout(() => list.push(performance.now() - start), 0)); + }; + addEventListener("keydown", toPaint(bench.keys), true); + addEventListener("click", toPaint(bench.taps), true); + new MutationObserver((_, observer) => { + if (document.getElementById("first-name") !== null) { + bench.ready = performance.now(); + observer.disconnect(); + } + }).observe(document, { childList: true, subtree: true }); + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) + if (entry.interactionId > 0) + bench.interactions[entry.interactionId] = Math.max( + bench.interactions[entry.interactionId] ?? 0, + entry.duration, + ); + }).observe({ type: "event", buffered: true, durationThreshold: 16 }); +}; + +const median = (values) => { + const sorted = [...values].sort((a, b) => a - b); + return sorted.length === 0 ? 0 : sorted[Math.floor(sorted.length / 2)]; +}; +const percentile = (values, p) => { + const sorted = [...values].sort((a, b) => a - b); + return sorted.length === 0 + ? 0 + : sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]; +}; + +const typing = { delay: 60 }; + +/** One scripted application: every step, the state sheet, and the code. */ +const session = async (page) => { + // Center the field first, as a person scrolls to a field before tapping it. + const type = async (selector, text) => { + await page.locator(selector).evaluate((element) => element.scrollIntoView({ block: "center" })); + await page.tap(selector); + await page.locator(selector).pressSequentially(text, typing); + }; + await type("#first-name", "Ada"); + await type("#last-name", "Lovelace"); + await type("#email", "ada@example.com"); + await type("#phone", "5551234567"); + await type("#dob", "07041990"); + await page.tap("text=Continue"); + await page.waitForSelector("#street"); + await type("#street", "1 Main Street"); + await type("#city", "Oakland"); + await type("#zip", "94612"); + await page.tap("#state"); + await page.waitForSelector("#state-search"); + await page.locator("#state-search").pressSequentially("cal", typing); + await page.tap('dialog[open] button:has-text("California")'); + await page.waitForSelector("dialog[open]", { state: "detached", timeout: 2000 }).catch(() => {}); + await page.tap("text=Continue"); + await page.waitForSelector("#hours"); + await page.tap("label:has(#employment-part-time)"); + for (let index = 0; index < 5; index++) { + await page.tap('[aria-label="More hours"]'); + await page.waitForTimeout(80); + } + await page.fill("#start-date", "2031-01-15"); + await page.tap("label:has(#shift-evenings)"); + await page.tap("label:has(#shift-weekends)"); + await page.tap("text=Continue"); + // Conditional questions: each "Yes" animates follow-up fields open. + await page.waitForSelector("#license-yes", { state: "attached" }); + await page.tap("label:has(#license-yes)"); + await type("#license-number", "D1234567"); + await page.tap("label:has(#cdl-yes)"); + await page.tap("label:has(#cdl-class-b)"); + await page.tap("label:has(#rehire-yes)"); + await type("#previous-location", "Oakland"); + await type("#previous-end", "062022"); + await page.tap("text=Continue"); + await page.waitForSelector("#otp"); + await page.locator("#otp").pressSequentially("123456", typing); + // Implementations may submit a complete code themselves; otherwise press Verify. + if (!(await page.waitForSelector("text=You're all set", { timeout: 500 }).catch(() => null))) + await page.tap("button[type=submit]"); + await page.waitForSelector("text=You're all set"); +}; + +const metric = (metrics, name) => metrics.find((entry) => entry.name === name)?.value ?? 0; + +const runOnce = async (browser, url) => { + const context = await browser.newContext({ ...devices["Pixel 7"] }); + await context.addInitScript(probe); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Performance.enable"); + await cdp.send("Network.emulateNetworkConditions", { + offline: false, + latency: 150, + downloadThroughput: (1.6 * 1024 * 1024) / 8, + uploadThroughput: (750 * 1024) / 8, + }); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpu }); + let transfer = 0; + cdp.on("Network.loadingFinished", (event) => { + transfer += event.encodedDataLength; + }); + await page.goto(url, { waitUntil: "load" }); + await page.waitForSelector("#first-name"); + await page.waitForFunction( + () => performance.getEntriesByName("first-contentful-paint").length > 0, + ); + const load = await page.evaluate(() => ({ + fcp: performance.getEntriesByName("first-contentful-paint")[0].startTime, + ready: window.__bench.ready, + })); + await cdp.send("Network.emulateNetworkConditions", { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }); + const before = (await cdp.send("Performance.getMetrics")).metrics; + await session(page).catch(async (error) => { + await page.screenshot({ path: join(tmpdir(), "mobile-form-bench-failure.png") }); + throw error; + }); + await page.waitForTimeout(300); + const after = (await cdp.send("Performance.getMetrics")).metrics; + const probes = await page.evaluate(() => window.__bench); + await context.close(); + return { + transferKB: transfer / 1024, + fcp: load.fcp, + ready: load.ready, + keys: probes.keys, + taps: probes.taps, + inp: Math.max(0, ...Object.values(probes.interactions)), + slow: Object.values(probes.interactions).filter((duration) => duration >= 50).length, + scriptMs: (metric(after, "ScriptDuration") - metric(before, "ScriptDuration")) * 1000, + heapMB: metric(after, "JSHeapUsedSize") / 1024 / 1024, + }; +}; + +const browser = await chromium.launch({ channel: process.env.BENCH_CHANNEL ?? "chrome" }); +const rows = []; +for (const target of targets) { + const server = /^https?:/.test(target.location) ? undefined : await serve(target.location); + const url = server?.url ?? target.location; + const results = []; + for (let run = 0; run < runs; run++) { + results.push(await runOnce(browser, url)); + process.stderr.write(`${target.label}: run ${run + 1}/${runs}\n`); + } + server?.close(); + const keys = results.flatMap((result) => result.keys); + const taps = results.flatMap((result) => result.taps); + rows.push({ + label: target.label, + transferKB: median(results.map((result) => result.transferKB)), + fcp: median(results.map((result) => result.fcp)), + ready: median(results.map((result) => result.ready)), + keyP50: percentile(keys, 50), + keyP95: percentile(keys, 95), + keyMax: Math.max(...keys), + tapP50: percentile(taps, 50), + tapP95: percentile(taps, 95), + inp: median(results.map((result) => result.inp)), + slow: median(results.map((result) => result.slow)), + scriptMs: median(results.map((result) => result.scriptMs)), + heapMB: median(results.map((result) => result.heapMB)), + samples: `${keys.length} keys, ${taps.length} taps`, + }); +} +await browser.close(); + +const fixed = (value, digits = 0) => value.toFixed(digits); +console.log( + `\nPixel 7 emulation, ${cpu}x CPU slowdown, Slow 4G for the first load, ${runs} runs each.\n`, +); +console.log( + "| Target | Transfer | FCP | Form ready | Key → paint p50 / p95 / max | Tap → paint p50 / p95 | INP | Interactions ≥ 50ms | Script time | JS heap |", +); +console.log("|---|---|---|---|---|---|---|---|---|---|"); +for (const row of rows) + console.log( + `| ${row.label} | ${fixed(row.transferKB, 1)} KB | ${fixed(row.fcp)} ms | ${fixed(row.ready)} ms | ${fixed(row.keyP50, 1)} / ${fixed(row.keyP95, 1)} / ${fixed(row.keyMax, 1)} ms | ${fixed(row.tapP50, 1)} / ${fixed(row.tapP95, 1)} ms | ${fixed(row.inp)} ms | ${row.slow} | ${fixed(row.scriptMs)} ms | ${fixed(row.heapMB, 1)} MB |`, + ); +console.log( + `\nSamples per target: ${rows.map((row) => `${row.label}: ${row.samples}`).join("; ")}`, +); diff --git a/apps/demo/e2e/mobile-form.scenarios.ts b/apps/demo/e2e/mobile-form.scenarios.ts new file mode 100644 index 0000000..ec41c36 --- /dev/null +++ b/apps/demo/e2e/mobile-form.scenarios.ts @@ -0,0 +1,109 @@ +import type { Page } from "playwright"; +import { describe, expect, it } from "vitest"; + +export const mobileFormScenarios = (getPage: () => Page, appUrl: string) => { + describe("mobile inputs", () => { + it("guides a phone through the candidate form with masks, the state sheet, and a code", async () => { + const page = getPage(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${appUrl}/mobile-form`, { waitUntil: "networkidle" }); + await page.evaluate(() => localStorage.removeItem("foldworks-mobile-form")); + await page.reload({ waitUntil: "networkidle" }); + const field = (name: string) => page.getByRole("textbox", { name, exact: true }); + + await page.getByRole("button", { name: "Continue" }).click(); + await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("first-name"); + await expect.poll(() => page.getByText("Enter your first name.").isVisible()).toBe(true); + + await field("First name").pressSequentially("Ada"); + await field("First name").press("Enter"); + await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("last-name"); + await page.keyboard.type("Lovelace"); + await field("Email").fill("ada@gmial.com"); + await page.getByRole("button", { name: /Did you mean/ }).click(); + await expect.poll(() => field("Email").inputValue()).toBe("ada@gmail.com"); + await field("Mobile phone").pressSequentially("5551234567"); + await expect.poll(() => field("Mobile phone").inputValue()).toBe("(555) 123-4567"); + // Typing in the middle keeps the caret after the inserted digit. + await field("Mobile phone").evaluate((input: HTMLInputElement) => + input.setSelectionRange(8, 8), + ); + await page.keyboard.type("9"); + expect(await field("Mobile phone").inputValue()).toBe("(555) 129-3456"); + expect( + await field("Mobile phone").evaluate((input: HTMLInputElement) => input.selectionStart), + ).toBe(9); + await field("Mobile phone").fill("+1 (555) 123-4567"); + await expect.poll(() => field("Mobile phone").inputValue()).toBe("(555) 123-4567"); + await field("Date of birth").pressSequentially("07041990"); + await expect.poll(() => field("Date of birth").inputValue()).toBe("07/04/1990"); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("heading", { name: "Where do you live?" }).waitFor(); + await field("Street address").fill("1 Main Street"); + await field("City").fill("Oakland"); + await field("ZIP code").pressSequentially("94612-1234"); + await expect.poll(() => field("ZIP code").inputValue()).toBe("94612"); + await page.locator("#state").click(); + const sheet = page.getByRole("dialog", { name: "State" }); + await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("state-search"); + await page.keyboard.type("new y"); + await expect.poll(() => sheet.getByRole("button").allTextContents()).toEqual(["New YorkNY"]); + await page.keyboard.press("Enter"); + await expect.poll(() => sheet.isVisible()).toBe(false); + await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("state"); + expect(await page.locator("#state").innerText()).toContain("New York"); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("heading", { name: "Your availability" }).waitFor(); + await page.getByText("Part-time", { exact: true }).click(); + for (let index = 0; index < 3; index++) + await page.getByRole("button", { name: "More hours" }).click(); + await expect.poll(() => page.locator("#hours").inputValue()).toBe("23"); + await page.locator("#start-date").fill("2099-01-15"); + await page.getByText("Evenings", { exact: true }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("heading", { name: "A few more questions" }).waitFor(); + const licenseFollowUp = page.locator("#license-follow-up"); + expect(await licenseFollowUp.getAttribute("inert")).not.toBeNull(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect.poll(() => page.getByText("Choose yes or no.").count()).toBe(2); + await page.locator("label:has(#license-yes)").click(); + await expect.poll(() => licenseFollowUp.getAttribute("inert")).toBeNull(); + await field("License number").fill("D1234567"); + await page.locator("label:has(#cdl-yes)").click(); + await page.getByRole("button", { name: "Continue" }).click(); + // The nested class is now required; answering "No" above hides and releases it. + await expect.poll(() => page.getByText("Choose a CDL class.").isVisible()).toBe(true); + await page.locator("label:has(#license-no)").click(); + await expect.poll(() => licenseFollowUp.getAttribute("inert")).not.toBeNull(); + await expect + .poll(() => licenseFollowUp.evaluate((element) => element.getBoundingClientRect().height)) + .toBe(0); + await page.locator("label:has(#license-yes)").click(); + await expect.poll(() => field("License number").inputValue()).toBe("D1234567"); + await page.locator("label:has(#license-no)").click(); + await page.locator("label:has(#rehire-yes)").click(); + await field("Which location?").fill("Oakland"); + await field("When did you leave?").pressSequentially("062022"); + await expect.poll(() => field("When did you leave?").inputValue()).toBe("06/2022"); + await page.getByRole("button", { name: "Continue" }).click(); + + await page.getByRole("heading", { name: "Verify your phone" }).waitFor(); + await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe("otp"); + await page.keyboard.type("12a3456"); + await page.getByRole("heading", { name: "You're all set" }).waitFor(); + expect( + await page.evaluate( + () => JSON.parse(localStorage.getItem("foldworks-mobile-form")!).values.state, + ), + ).toBe("NY"); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); + }); + }); +}; diff --git a/apps/demo/e2e/workflow.e2e.test.ts b/apps/demo/e2e/workflow.e2e.test.ts index 3152a55..99fa9fd 100644 --- a/apps/demo/e2e/workflow.e2e.test.ts +++ b/apps/demo/e2e/workflow.e2e.test.ts @@ -1,5 +1,6 @@ import { documentAnalysisScenarios } from "./document-analysis.scenarios"; import { themeBuilderScenarios } from "./theme-builder.scenarios"; +import { mobileFormScenarios } from "./mobile-form.scenarios"; import { docsScenarios } from "./docs.scenarios"; import { desktopScenarios } from "./desktop.scenarios"; import { mkdir, readFile } from "node:fs/promises"; @@ -160,6 +161,7 @@ describe("structured workflow builder", { concurrent: false }, () => { "Text intelligence", "PDF rendering", "UI system", + "Mobile inputs", "Theme builder", ]); await expect.poll(() => sidebar.getByText("Foldworks", { exact: true }).count()).toBe(1); @@ -1639,6 +1641,7 @@ describe("structured workflow builder", { concurrent: false }, () => { }, 60_000); themeBuilderScenarios(() => page, appUrl); + mobileFormScenarios(() => page, appUrl); workbenchScenarios(() => page, appUrl, screenshot); dataGridEditingScenarios(() => page, appUrl, screenshot); dataGridCoverageScenarios(() => page, appUrl, screenshot); diff --git a/apps/demo/package.json b/apps/demo/package.json index e0075ec..0589c53 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build && vite build --config vite.mcp-app.config.ts", + "build": "vite build && vite build --config vite.mcp-app.config.ts && vite build --config vite.mobile-form.config.ts", "preview": "vite preview", + "bench:mobile-form": "vite build --config vite.mobile-form.config.ts --outDir .bench/foldkit && node bench/mobile-form.mjs foldkit=.bench/foldkit/apply.html", "typecheck": "tsc --noEmit", "test": "vitest run src", "test:e2e": "vitest run --config vitest.e2e.config.ts", diff --git a/apps/demo/src/app/message.ts b/apps/demo/src/app/message.ts index 8d92007..f77853e 100644 --- a/apps/demo/src/app/message.ts +++ b/apps/demo/src/app/message.ts @@ -24,6 +24,7 @@ import { ThemeName, ThemePreference } from "../theme"; import { Message as StatechartMessage } from "../statechart/message"; import { Message as ThemeBuilderMessage } from "../theme-builder/message"; import { Message as UiKitMessage } from "../ui-kit/message"; +import { Message as MobileFormMessage } from "../mobile-form/message"; import { Message as WorkflowEditorMessage } from "../workflow/message"; export const Message = defineMessageUnion({ @@ -55,6 +56,7 @@ export const Message = defineMessageUnion({ GotEditorMessage: { message: EditorMessage }, GotSidebarMessage: { message: Sidebar.Message }, GotUiKitMessage: { message: UiKitMessage }, + GotMobileFormMessage: { message: MobileFormMessage }, GotThemeBuilderMessage: { message: ThemeBuilderMessage }, GotGenerativeUiAction: { intent: ActionIntent }, }); diff --git a/apps/demo/src/app/model.ts b/apps/demo/src/app/model.ts index 8bd48f4..2a08aa9 100644 --- a/apps/demo/src/app/model.ts +++ b/apps/demo/src/app/model.ts @@ -39,6 +39,7 @@ import { initialModel as initialThemeBuilder, } from "../theme-builder/model"; import { Model as UiKitModel, initialModel as initialUiKit } from "../ui-kit/model"; +import { Model as MobileFormModel, init as initMobileForm } from "../mobile-form/model"; import { Model as StatechartModel, initialModel as initialStatechart } from "../statechart/model"; import { Model as WorkflowEditorModel, init as initWorkflowEditor } from "../workflow/model"; import { @@ -70,6 +71,7 @@ export const Model = S.Struct({ editor: ArticleEditor.Model, sidebar: Sidebar.Model, uiKit: UiKitModel, + mobileForm: MobileFormModel, themeBuilder: ThemeBuilderModel, themeName: ThemeName, themePreference: ThemePreference, @@ -115,6 +117,7 @@ export const init = ( editor: initialEditor(), sidebar: Sidebar.init({ id: "foldworks-sidebar", defaultCollapsed: route._tag === "Theme" }), uiKit: initialUiKit, + mobileForm: initMobileForm(), themeBuilder: initialThemeBuilder(), themeName: theme.name, themePreference: theme.preference, diff --git a/apps/demo/src/app/route.test.ts b/apps/demo/src/app/route.test.ts index 50ce2ea..4628c1e 100644 --- a/apps/demo/src/app/route.test.ts +++ b/apps/demo/src/app/route.test.ts @@ -23,6 +23,7 @@ import { pdfViewerRouter, statechartRouter, uiKitRouter, + mobileFormRouter, themeRouter, urlToAppRoute, workflowOrientationFromRoute, @@ -78,6 +79,8 @@ describe("demo routes", () => { ); expect(uiKitRouter()).toBe("/ui-kit"); expect(themeRouter()).toBe("/theme"); + expect(mobileFormRouter()).toBe("/mobile-form"); + expect(demoFromRoute(parseUrl("https://demo.test/mobile-form"))).toBe("MobileForm"); expect(demoFromRoute(parseUrl("https://demo.test/theme#theme=state"))).toBe("Theme"); expect(queryBuilderRouter()).toBe("/query-builder"); expect(pdfAnnotatorRouter()).toBe("/pdf-annotator"); diff --git a/apps/demo/src/app/route.ts b/apps/demo/src/app/route.ts index 48bc0f9..e004ab1 100644 --- a/apps/demo/src/app/route.ts +++ b/apps/demo/src/app/route.ts @@ -26,6 +26,7 @@ export type Demo = | "PdfViewer" | "DocumentAnalysis" | "UiKit" + | "MobileForm" | "Theme"; export const WorkflowOrientation = S.Literals(["Vertical", "Horizontal"]); @@ -55,6 +56,7 @@ export const AppRoute = defineRouteUnion({ PdfViewer: {}, DocumentAnalysis: {}, UiKit: {}, + MobileForm: {}, Theme: {}, NotFound: { path: S.String }, }); @@ -130,6 +132,11 @@ export const themeRouter = pipe(Route.literal("theme"), Route.mapTo(AppRoute.The export const uiKitRouter = pipe(Route.literal("ui-kit"), Route.mapTo(AppRoute.UiKit)); +export const mobileFormRouter = pipe( + Route.literal("mobile-form"), + Route.mapTo(AppRoute.MobileForm), +); + export const queryBuilderRouter = pipe( Route.literal("query-builder"), Route.mapTo(AppRoute.QueryBuilder), @@ -171,6 +178,7 @@ const routeParser = Route.oneOf( pdfViewerRouter, documentAnalysisRouter, uiKitRouter, + mobileFormRouter, themeRouter, homeRouter, ); @@ -217,6 +225,8 @@ export const demoFromRoute = (route: AppRoute): Demo => { return "Theme"; case "UiKit": return "UiKit"; + case "MobileForm": + return "MobileForm"; case "Workflow": return "Workflow"; case "Statechart": diff --git a/apps/demo/src/app/update.ts b/apps/demo/src/app/update.ts index ced8bf5..e9dd343 100644 --- a/apps/demo/src/app/update.ts +++ b/apps/demo/src/app/update.ts @@ -34,6 +34,8 @@ import { update as updateStatechart } from "../statechart/update"; import { update as updateThemeBuilder } from "../theme-builder/update"; import { Message as ThemeBuilderMessage } from "../theme-builder/message"; import { update as updateUiKit } from "../ui-kit/update"; +import { Message as MobileFormMessage } from "../mobile-form/message"; +import { update as updateMobileForm } from "../mobile-form/update"; import { OutMessage as WorkflowOutMessage } from "../workflow/message"; import { setOrientation, update as updateWorkflow } from "../workflow/update"; import { @@ -161,17 +163,26 @@ const foldThemeBuilder = Update.foldChild({ toParentMessage: (message) => Message.GotThemeBuilderMessage({ message }), }); +const foldMobileForm = Update.foldChild({ + update: updateMobileForm, + read: (model: Model) => Option.some(model.mobileForm), + write: (model, mobileForm) => ({ ...model, mobileForm }), + toParentMessage: (message) => Message.GotMobileFormMessage({ message }), +}); + /** Commands to run when a route is entered, after its child state exists. */ export const enterRoute = (model: Model): UpdateReturn => - model.route._tag === "Theme" - ? foldThemeBuilder(model, ThemeBuilderMessage.Initialize()) - : model.route._tag === "Statechart" - ? foldStatechart(model, StatechartMessage.ClickedFit()) - : model.route._tag === "PdfViewer" - ? enterPdfViewer(model) - : model.route._tag === "DocumentAnalysis" - ? foldDocumentAnalysis(model, DocumentAnalysisMessage.Initialize()) - : { model }; + model.route._tag === "MobileForm" + ? foldMobileForm(model, MobileFormMessage.Initialize()) + : model.route._tag === "Theme" + ? foldThemeBuilder(model, ThemeBuilderMessage.Initialize()) + : model.route._tag === "Statechart" + ? foldStatechart(model, StatechartMessage.ClickedFit()) + : model.route._tag === "PdfViewer" + ? enterPdfViewer(model) + : model.route._tag === "DocumentAnalysis" + ? foldDocumentAnalysis(model, DocumentAnalysisMessage.Initialize()) + : { model }; const foldCodeEditor = Update.foldChild({ update: updateCodeEditor, @@ -431,6 +442,7 @@ export const update = (model: Model, message: Message): UpdateReturn => GotSidebarMessage: ({ message: childMessage }) => foldSidebar(model, childMessage), GotThemeBuilderMessage: ({ message }) => foldThemeBuilder(model, message), GotUiKitMessage: ({ message: childMessage }) => foldUiKit(model, childMessage), + GotMobileFormMessage: ({ message }) => foldMobileForm(model, message), GotGenerativeUiAction: ({ intent }) => ({ model: modifyFields(model, { announcement: () => diff --git a/apps/demo/src/app/view.ts b/apps/demo/src/app/view.ts index 316e743..4826342 100644 --- a/apps/demo/src/app/view.ts +++ b/apps/demo/src/app/view.ts @@ -23,6 +23,7 @@ import { Moon, Network, Palette, + Smartphone, Sun, Table2, Workflow as WorkflowIcon, @@ -51,6 +52,7 @@ import { view as queryBuilderView } from "../query-builder/view"; import { statechartSummary, view as statechartView } from "../statechart/view"; import { view as themeBuilderView } from "../theme-builder/view"; import { view as uiKitView } from "../ui-kit/view"; +import { view as mobileFormView } from "../mobile-form/view"; import { allNodes } from "../workflow/graph"; import { editorRouter, @@ -72,6 +74,7 @@ import { queryBuilderRouter, statechartRouter, uiKitRouter, + mobileFormRouter, themeRouter, workflowPath, type Demo, @@ -367,6 +370,13 @@ const navigationGroups = (model: Model): ReadonlyArray icon: Blocks, isActive: demo === "UiKit", }, + { + id: "mobile-form", + label: "Mobile inputs", + href: mobileFormRouter(), + icon: Smartphone, + isActive: demo === "MobileForm", + }, ], // The builder exposes the same token layer as the theme picker. Tokens: [ @@ -839,6 +849,13 @@ const content = (model: Model, h: HtmlBuilder): Html => { toParentMessage: (message) => Message.GotPdfViewerDemoMessage({ message }), }); } + if (demo === "MobileForm") + return h.submodel({ + slotId: "mobile-form-content", + model: model.mobileForm, + view: mobileFormView, + toParentMessage: (message) => Message.GotMobileFormMessage({ message }), + }); return h.submodel({ slotId: "ui-kit-content", model: model.uiKit, @@ -848,32 +865,34 @@ const content = (model: Model, h: HtmlBuilder): Html => { }; const documentTitle = (demo: Demo): string => - demo === "Theme" - ? "Theme builder · Foldworks" - : demo === "DocumentAnalysis" - ? "Document analysis · Foldworks" - : Match.value(demo).pipe( - Match.when("Docs", () => "Documentation · Foldworks"), - Match.when("Editor", () => "Document editor · Foldworks"), - Match.when("Orchestrator", () => "Outline workspace · Foldworks"), - Match.when("Agent", () => "Interactive agent · Foldworks"), - Match.when("CodeEditor", () => "Code editor · Foldworks"), - Match.when("Codebase", () => "Codebase workbench · Foldworks"), - Match.when("DiffViewer", () => "Change request · Foldworks"), - Match.when("Workbench", () => "Workers workbench · Foldworks"), - Match.when("Home", () => "Foldworks · The UI standard library for Foldkit and StyleX"), - Match.when("DataTable", () => "Data table · Foldworks"), - Match.when("DataGrid", () => "Data grid · Foldworks"), - Match.when("FormBuilder", () => "Form builder · Foldworks"), - Match.when("QueryBuilder", () => "Query builder · Foldworks"), - Match.when("Outliner", () => "Outliner · Foldworks"), - Match.when("PdfAnnotator", () => "PDF annotator · Foldworks"), - Match.when("PdfViewer", () => "PDF viewer · Foldworks"), - Match.when("UiKit", () => "UI components · Foldworks"), - Match.when("Workflow", () => "Workflow · Foldworks"), - Match.when("Statechart", () => "Statechart · Foldworks"), - Match.exhaustive, - ); + demo === "MobileForm" + ? "Mobile inputs · Foldworks" + : demo === "Theme" + ? "Theme builder · Foldworks" + : demo === "DocumentAnalysis" + ? "Document analysis · Foldworks" + : Match.value(demo).pipe( + Match.when("Docs", () => "Documentation · Foldworks"), + Match.when("Editor", () => "Document editor · Foldworks"), + Match.when("Orchestrator", () => "Outline workspace · Foldworks"), + Match.when("Agent", () => "Interactive agent · Foldworks"), + Match.when("CodeEditor", () => "Code editor · Foldworks"), + Match.when("Codebase", () => "Codebase workbench · Foldworks"), + Match.when("DiffViewer", () => "Change request · Foldworks"), + Match.when("Workbench", () => "Workers workbench · Foldworks"), + Match.when("Home", () => "Foldworks · The UI standard library for Foldkit and StyleX"), + Match.when("DataTable", () => "Data table · Foldworks"), + Match.when("DataGrid", () => "Data grid · Foldworks"), + Match.when("FormBuilder", () => "Form builder · Foldworks"), + Match.when("QueryBuilder", () => "Query builder · Foldworks"), + Match.when("Outliner", () => "Outliner · Foldworks"), + Match.when("PdfAnnotator", () => "PDF annotator · Foldworks"), + Match.when("PdfViewer", () => "PDF viewer · Foldworks"), + Match.when("UiKit", () => "UI components · Foldworks"), + Match.when("Workflow", () => "Workflow · Foldworks"), + Match.when("Statechart", () => "Statechart · Foldworks"), + Match.exhaustive, + ); export const view = (model: Model, h: HtmlBuilder): Document => { const demo = demoFromRoute(model.route); diff --git a/apps/demo/src/entry.ts b/apps/demo/src/entry.ts index a430afb..dc92c2a 100644 --- a/apps/demo/src/entry.ts +++ b/apps/demo/src/entry.ts @@ -1,6 +1,7 @@ import { Runtime } from "foldkit"; import { Message, Model, init, subscriptions, update, view } from "./main"; +import { slowConfig } from "./mobile-form/performance"; const application = Runtime.makeApplication({ Model, @@ -20,11 +21,9 @@ const application = Runtime.makeApplication({ // The editor owns large document snapshots; recording each keystroke stalls the demo in development. excludeFromHistory: ["GotCodeEditorMessage"], }, - // Keep slow-phase warnings useful without sending the whole editor model through Vite's console bridge. - slow: { - onSlow: ({ _tag, durationMs }) => - console.warn(`[foldkit] Slow ${_tag}: ${durationMs.toFixed(1)}ms`), - }, + // Record every phase for the mobile form's live timings; warnings stay development-only + // and never send the whole editor model through Vite's console bridge. + slow: slowConfig(), viewTransition: ({ previousModel, model }) => previousModel.revision !== model.revision, }); diff --git a/apps/demo/src/mobile-form/form-view.ts b/apps/demo/src/mobile-form/form-view.ts new file mode 100644 index 0000000..6a16d2e --- /dev/null +++ b/apps/demo/src/mobile-form/form-view.ts @@ -0,0 +1,666 @@ +import type { Html, HtmlBuilder } from "foldkit/html"; +import { Check, ChevronLeft, Search } from "@lucide/icons"; +import * as Dialog from "@foldkit/ui/dialog"; +import * as Icon from "@foldworks/ui/icon"; +import { Option } from "effect"; +import { formatPhone } from "./format"; +import { + bottomSheet, + choiceCards, + reveal, + yesNo, + chips, + codeInput, + KeyboardInset, + pickerTrigger, + stepper, + textField, + toggle, +} from "./inputs"; +import { Message } from "./message"; +import { + formSteps, + type Employment, + type FieldId, + type Model, + type Shift, + type YesNoQuestion, + type TextFieldId, +} from "./model"; +import { filterStates, stateName, usStates } from "./states"; +import { inputStyles } from "./inputs.styles"; +import { className, styles } from "./styles"; +import { stepTitle } from "./update"; +import { emailFix, fieldError } from "./validation"; + +type H = HtmlBuilder; + +const errorFor = (model: Model, field: FieldId): string | undefined => + model.visited.includes(field) ? fieldError(field, model.values, model.today) : undefined; + +const text = (model: Model, field: TextFieldId) => ({ + id: field, + value: model.values.text[field], + onInput: (value: string) => Message.ChangedText({ field, value }), + onBlur: Message.LeftField({ field }), + error: errorFor(model, field), +}); + +const about = (model: Model, h: H): ReadonlyArray => { + const fix = emailFix(model.values.text.email); + return [ + h.div( + [h.Class(className(styles.pair))], + [ + textField( + { + ...text(model, "first-name"), + label: "First name", + autocomplete: "given-name", + autocapitalize: "words", + next: "last-name", + }, + h, + ), + textField( + { + ...text(model, "last-name"), + label: "Last name", + autocomplete: "family-name", + autocapitalize: "words", + next: "email", + }, + h, + ), + ], + ), + textField( + { + ...text(model, "email"), + label: "Email", + type: "email", + inputMode: "email", + autocomplete: "email", + autocapitalize: "none", + spellcheck: false, + next: "phone", + after: + fix === undefined + ? undefined + : h.button( + [ + h.Type("button"), + h.Class(className(inputStyles.suggestion)), + h.OnClick(Message.AcceptedEmailFix({ email: fix })), + ], + ["Did you mean ", h.strong([], [fix]), "?"], + ), + }, + h, + ), + textField( + { + ...text(model, "phone"), + label: "Mobile phone", + hint: "We'll text a code to confirm it's you.", + type: "tel", + inputMode: "tel", + autocomplete: "tel-national", + placeholder: "(555) 123-4567", + mask: "Phone", + next: "dob", + }, + h, + ), + textField( + { + ...text(model, "dob"), + label: "Date of birth", + inputMode: "numeric", + autocomplete: "bday", + placeholder: "MM/DD/YYYY", + mask: "Date", + }, + h, + ), + ]; +}; + +const address = (model: Model, h: H): ReadonlyArray => [ + textField( + { + ...text(model, "street"), + label: "Street address", + autocomplete: "address-line1", + autocapitalize: "words", + next: "apt", + }, + h, + ), + textField( + { + ...text(model, "apt"), + label: "Apt, suite, unit", + optional: true, + autocomplete: "address-line2", + next: "city", + }, + h, + ), + textField( + { + ...text(model, "city"), + label: "City", + autocomplete: "address-level2", + autocapitalize: "words", + next: "zip", + }, + h, + ), + h.div( + [h.Class(className(styles.pair))], + [ + pickerTrigger( + { + id: "state", + label: "State", + displayValue: stateName(model.values.state), + placeholder: "Choose", + onOpen: Message.OpenedStateSheet(), + error: errorFor(model, "state"), + autofill: { + autocomplete: "address-level1", + value: model.values.state, + options: usStates.map((state) => ({ value: state.code, label: state.name })), + onChange: (code) => Message.AutofilledState({ code }), + }, + }, + h, + ), + textField( + { + ...text(model, "zip"), + label: "ZIP code", + inputMode: "numeric", + autocomplete: "postal-code", + mask: "Zip", + maxLength: 10, + }, + h, + ), + ], + ), +]; + +const employment: ReadonlyArray<{ value: Employment; label: string; description: string }> = [ + { value: "full-time", label: "Full-time", description: "35+ hours a week, with benefits" }, + { value: "part-time", label: "Part-time", description: "Flexible hours around your schedule" }, + { value: "seasonal", label: "Seasonal", description: "Peak periods, a few months at a time" }, +]; + +const shifts: ReadonlyArray<{ value: Shift; label: string }> = [ + { value: "mornings", label: "Mornings" }, + { value: "afternoons", label: "Afternoons" }, + { value: "evenings", label: "Evenings" }, + { value: "overnight", label: "Overnight" }, + { value: "weekends", label: "Weekends" }, +]; + +const work = (model: Model, h: H): ReadonlyArray => [ + choiceCards( + { + name: "employment", + legend: "How do you want to work?", + options: employment, + value: model.values.employment, + onSelect: (value) => Message.SelectedEmployment({ value }), + error: errorFor(model, "employment"), + }, + h, + ), + stepper( + { + id: "hours", + label: "Hours per week", + value: model.values.hours, + min: 1, + max: 60, + suffix: "hrs", + onStep: (delta) => Message.SteppedHours({ delta }), + onInput: (value) => Message.ChangedHours({ value }), + error: errorFor(model, "hours"), + }, + h, + ), + textField( + { + ...text(model, "start-date"), + label: "Earliest start date", + type: "date", + autocomplete: "off", + ...(model.today === "" ? {} : { min: model.today }), + }, + h, + ), + chips( + { + name: "shift", + legend: "Which shifts work for you?", + options: shifts, + selected: model.values.shifts, + onToggle: (shift) => Message.ToggledShift({ shift }), + error: errorFor(model, "shifts"), + }, + h, + ), + toggle( + { + id: "alerts", + label: "Text me shift alerts", + description: "Open shifts near you, up to 3 a week.", + checked: model.values.alerts, + onToggle: (value) => Message.ToggledAlerts({ value }), + }, + h, + ), +]; + +const question = ( + model: Model, + name: YesNoQuestion, + legend: string, + followUp: ReadonlyArray, + h: H, +): Html => + h.div( + [h.Class(className(inputStyles.question))], + [ + yesNo( + { + name, + legend, + value: model.values.answers[name], + onAnswer: (answer) => Message.Answered({ question: name, answer }), + error: errorFor(model, name), + }, + h, + ), + reveal( + { + id: `${name}-follow-up`, + isOpen: model.values.answers[name] === "yes", + children: followUp, + }, + h, + ), + ], + ); + +const background = (model: Model, h: H): ReadonlyArray => [ + question( + model, + "license", + "Do you have a valid driver's license?", + [ + textField( + { + ...text(model, "license-number"), + label: "License number", + autocomplete: "off", + autocapitalize: "characters", + spellcheck: false, + next: `cdl-${model.values.answers.cdl || "yes"}`, + }, + h, + ), + // Conditions nest: the class only matters for a commercial license. + question( + model, + "cdl", + "Is it a commercial license (CDL)?", + [ + choiceCards( + { + name: "cdl-class", + legend: "CDL class", + options: [ + { value: "a", label: "Class A", description: "Tractor-trailers and combinations" }, + { value: "b", label: "Class B", description: "Straight trucks and buses" }, + { value: "c", label: "Class C", description: "Passenger vans and hazmat" }, + ], + value: model.values.cdlClass, + onSelect: (value) => Message.SelectedCdlClass({ value }), + error: errorFor(model, "cdl-class"), + }, + h, + ), + ], + h, + ), + ], + h, + ), + question( + model, + "rehire", + "Have you worked for us before?", + [ + textField( + { + ...text(model, "previous-location"), + label: "Which location?", + autocomplete: "off", + autocapitalize: "words", + next: "previous-end", + }, + h, + ), + textField( + { + ...text(model, "previous-end"), + label: "When did you leave?", + inputMode: "numeric", + autocomplete: "off", + placeholder: "MM/YYYY", + mask: "Month", + }, + h, + ), + ], + h, + ), +]; + +const verify = (model: Model, h: H): ReadonlyArray => [ + h.p( + [h.Class(className(styles.lede))], + [ + "Enter the 6-digit code we sent to ", + h.strong([], [formatPhone(model.values.text.phone) || "your phone"]), + ". ", + h.button( + [ + h.Type("button"), + h.Class(className(styles.inlineLink)), + h.OnClick(Message.PressedEditPhone()), + ], + ["Edit"], + ), + ], + ), + codeInput( + { + id: "otp", + label: "Verification code", + value: model.values.text.otp, + length: 6, + onInput: (value) => Message.ChangedText({ field: "otp", value }), + error: errorFor(model, "otp"), + }, + h, + ), + h.button( + [h.Type("button"), h.Class(className(styles.secondary)), h.OnClick(Message.PressedResend())], + ["Resend code"], + ), +]; + +const done = (model: Model, h: H): Html => { + const values = model.values; + const rows: ReadonlyArray = [ + ["Name", `${values.text["first-name"]} ${values.text["last-name"]}`], + ["Email", values.text.email], + ["Phone", values.text.phone], + ["State", stateName(values.state)], + ]; + return h.div( + [h.Class(className(styles.done))], + [ + h.div( + [h.Class(className(styles.doneBadge))], + [Icon.view({ icon: Check, size: 36, strokeWidth: 3 }, h)], + ), + h.h1( + [h.Id("mobile-form-heading"), h.Tabindex(-1), h.Class(className(styles.heading))], + [stepTitle("Done")], + ), + h.p( + [h.Class(className(styles.asideText))], + ["Thanks for applying. We'll text you about next steps."], + ), + h.dl( + [h.Class(className(styles.summary))], + rows.flatMap(([term, value]) => [ + h.dt([h.Class(className(styles.summaryTerm))], [term]), + h.dd([h.Class(className(styles.summaryValue))], [value]), + ]), + ), + h.button( + [ + h.Type("button"), + h.Class(className(styles.secondary)), + h.OnClick(Message.PressedStartOver()), + ], + ["Start over"], + ), + ], + ); +}; + +const ledes: Readonly, string>> = { + About: "It takes about two minutes. Your progress is saved as you go.", + Address: "Autofill fills this whole page in one tap.", + Work: "Pick everything that could work. You can change it later.", + Background: "Answer yes and we'll ask a follow-up. Answer no and it disappears.", +}; + +const stepBody = (model: Model, h: H): ReadonlyArray => { + switch (model.step) { + case "About": + return about(model, h); + case "Address": + return address(model, h); + case "Work": + return work(model, h); + case "Background": + return background(model, h); + case "Verify": + return verify(model, h); + case "Done": + return []; + } +}; + +const topBar = (model: Model, h: H): Html => { + const index = formSteps.indexOf(model.step as (typeof formSteps)[number]); + const position = index === -1 ? formSteps.length : index + 1; + return h.header( + [h.Class(className(styles.top))], + [ + index > 0 + ? h.button( + [ + h.Type("button"), + h.Class(className(styles.iconButton)), + h.AriaLabel("Back"), + h.OnClick(Message.PressedBack()), + ], + [Icon.view({ icon: ChevronLeft, size: 24 }, h)], + ) + : h.span([], []), + h.div( + [h.Class(className(styles.progress))], + [ + h.span( + [h.Class(className(styles.progressLabel))], + [index === -1 ? "Complete" : `Step ${position} of ${formSteps.length}`], + ), + h.div( + [ + h.Class(className(styles.progressTrack)), + h.Role("progressbar"), + h.AriaLabel("Application progress"), + h.AriaValuemin(0), + h.AriaValuemax(formSteps.length), + h.AriaValuenow(index === -1 ? formSteps.length : index), + ], + [ + h.div([ + h.Class(className(styles.progressFill)), + h.Style({ + transform: `scaleX(${(index === -1 ? formSteps.length : index + 1) / formSteps.length})`, + }), + ]), + ], + ), + ], + ), + h.span([], []), + ], + ); +}; + +const stateSheet = (model: Model, h: H): Html => { + const matches = filterStates(model.stateQuery); + return h.submodel({ + slotId: "mobile-form-state-sheet", + model: model.stateSheet, + view: Dialog.view, + toParentMessage: (message) => Message.GotStateSheetMessage({ message }), + viewInputs: bottomSheet( + { + title: "State", + content: (initialFocus) => [ + h.div( + [h.Class(className(styles.sheetSearch))], + [ + h.label( + [h.For("state-search"), h.Class(className(styles.srOnly))], + ["Search states"], + ), + h.div( + [h.Style({ position: "relative" })], + [ + h.input([ + ...initialFocus, + h.Id("state-search"), + h.Type("search"), + h.Placeholder("Search"), + h.Autocomplete("off"), + h.Autocapitalize("none"), + h.Spellcheck(false), + h.EnterKeyHint("done"), + h.Value(model.stateQuery), + h.Class(className(inputStyles.control, styles.searchControl)), + h.OnInput((value) => Message.ChangedStateQuery({ value })), + h.OnKeyDownPreventDefault((key) => + key === "Enter" && matches[0] !== undefined + ? Option.some(Message.ChosenState({ code: matches[0].code })) + : Option.none(), + ), + ]), + h.span( + [h.Class(className(styles.searchIcon))], + [Icon.view({ icon: Search, size: 18 }, h)], + ), + ], + ), + ], + ), + matches.length === 0 + ? h.p([h.Class(className(styles.sheetEmpty))], ["No matching state"]) + : h.ul( + [h.Class(className(styles.sheetList)), h.AriaLabel("States")], + matches.map((state) => + h.keyed("li")( + state.code, + [], + [ + h.button( + [ + h.Type("button"), + h.Class(className(styles.sheetRow)), + h.AriaPressed(model.values.state === state.code ? "true" : "false"), + h.OnClick(Message.ChosenState({ code: state.code })), + ], + [ + h.span( + [], + [ + state.name, + h.span([h.Class(className(styles.sheetCode))], [state.code]), + ], + ), + model.values.state === state.code + ? Icon.view({ icon: Check, size: 20, strokeWidth: 2.5 }, h) + : h.empty, + ], + ), + ], + ), + ), + ), + ], + }, + h, + ), + }); +}; + +/** The form application itself, shared by the demo route and the standalone build. */ +export const formApp = (model: Model, h: H): Html => + h.div( + [h.Class(className(styles.app)), h.OnMount(KeyboardInset())], + [ + topBar(model, h), + h.div( + [h.Id("mobile-form-scroll"), h.Class(className(styles.scroll))], + [ + model.step === "Done" + ? done(model, h) + : h.form( + [ + h.Id("mobile-form"), + h.Class(className(styles.form)), + h.Novalidate(true), + h.OnSubmit(Message.Submitted()), + ], + [ + h.keyed("div")( + model.step, + [h.Class(className(styles.body))], + [ + h.h1( + [ + h.Id("mobile-form-heading"), + h.Tabindex(-1), + h.Class(className(styles.heading)), + ], + [stepTitle(model.step)], + ), + model.step === "Verify" + ? h.empty + : h.p([h.Class(className(styles.lede))], [ledes[model.step]]), + ...stepBody(model, h), + ], + ), + h.div( + [h.Class(className(styles.actions))], + [ + h.button( + [h.Type("submit"), h.Class(className(styles.primary))], + [model.step === "Verify" ? "Verify" : "Continue"], + ), + ], + ), + ], + ), + ], + ), + h.div( + [h.Class(className(styles.srOnly)), h.Role("status"), h.AriaLive("polite")], + [model.announcement], + ), + stateSheet(model, h), + ], + ); diff --git a/apps/demo/src/mobile-form/format.test.ts b/apps/demo/src/mobile-form/format.test.ts new file mode 100644 index 0000000..ecba075 --- /dev/null +++ b/apps/demo/src/mobile-form/format.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + ageOn, + caretAfterDigits, + emailSuggestion, + formatDate, + formatMonth, + formatPhone, + formatZip, + parseDate, + parseMonth, +} from "./format"; +import { filterStates } from "./states"; + +describe("mobile form formatting", () => { + it("formats phone numbers as they are typed and accepts autofilled country codes", () => { + expect(formatPhone("555")).toBe("555"); + expect(formatPhone("5551")).toBe("(555) 1"); + expect(formatPhone("5551234567")).toBe("(555) 123-4567"); + expect(formatPhone("+1 (555) 123-4567")).toBe("(555) 123-4567"); + expect(formatPhone("15551234567")).toBe("(555) 123-4567"); + expect(formatPhone("555123456789")).toBe("(555) 123-4567"); + }); + + it("formats dates and ZIP codes from digits alone", () => { + expect(formatDate("07")).toBe("07"); + expect(formatDate("0704")).toBe("07/04"); + expect(formatDate("07/04/1990")).toBe("07/04/1990"); + expect(formatDate("0704199012")).toBe("07/04/1990"); + expect(formatZip("94612-1234")).toBe("94612"); + expect(formatMonth("06")).toBe("06"); + expect(formatMonth("062022")).toBe("06/2022"); + expect(parseMonth("06/2022")).toBe("2022-06"); + expect(parseMonth("13/2022")).toBeUndefined(); + }); + + it("places the caret after the same digit once separators move", () => { + expect(caretAfterDigits("(555) 129-3456", 6)).toBe(9); + expect(caretAfterDigits("(555) 123-4567", 3)).toBe(4); + expect(caretAfterDigits("(555) 1", 0)).toBe(1); + expect(caretAfterDigits("07/04", 4)).toBe(5); + }); + + it("validates real calendar dates and ages", () => { + expect(parseDate("02/29/2024")).toEqual({ year: 2024, month: 2, day: 29 }); + expect(parseDate("02/29/2023")).toBeUndefined(); + expect(parseDate("13/01/2000")).toBeUndefined(); + expect(ageOn({ year: 2000, month: 7, day: 4 }, { year: 2016, month: 7, day: 3 })).toBe(15); + expect(ageOn({ year: 2000, month: 7, day: 4 }, { year: 2016, month: 7, day: 4 })).toBe(16); + }); + + it("suggests corrections for common email domain typos only", () => { + expect(emailSuggestion("ada@gmial.com")).toBe("ada@gmail.com"); + expect(emailSuggestion("ada@hotmial.com")).toBe("ada@hotmail.com"); + expect(emailSuggestion("ada@example.com")).toBeUndefined(); + }); + + it("finds states by name, word prefix, or postal code", () => { + expect(filterStates("cal").map((state) => state.code)).toEqual(["CA"]); + expect(filterStates("york").map((state) => state.code)).toEqual(["NY"]); + expect(filterStates("tx").map((state) => state.code)).toEqual(["TX"]); + expect(filterStates("")).toHaveLength(51); + }); +}); diff --git a/apps/demo/src/mobile-form/format.ts b/apps/demo/src/mobile-form/format.ts new file mode 100644 index 0000000..5db8c01 --- /dev/null +++ b/apps/demo/src/mobile-form/format.ts @@ -0,0 +1,119 @@ +/** + * Pure formatting and validation for the mobile candidate form. Masks keep + * only digits in the model's source of truth and derive the display string, so + * autofill, paste, and mid-string edits all converge on the same value. + */ + +export const digitsOf = (value: string): string => value.replace(/\D/g, ""); + +/** Accepts autofilled `+1 (555) 123-4567` and `15551234567` as ten national digits. */ +export const phoneDigits = (value: string): string => { + const digits = digitsOf(value); + return (digits.length > 10 && digits.startsWith("1") ? digits.slice(1) : digits).slice(0, 10); +}; + +export const formatPhone = (value: string): string => { + const d = phoneDigits(value); + if (d.length <= 3) return d; + if (d.length <= 6) return `(${d.slice(0, 3)}) ${d.slice(3)}`; + return `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}`; +}; + +export const formatDate = (value: string): string => { + const d = digitsOf(value).slice(0, 8); + if (d.length <= 2) return d; + if (d.length <= 4) return `${d.slice(0, 2)}/${d.slice(2)}`; + return `${d.slice(0, 2)}/${d.slice(2, 4)}/${d.slice(4)}`; +}; + +export const formatMonth = (value: string): string => { + const d = digitsOf(value).slice(0, 6); + return d.length <= 2 ? d : `${d.slice(0, 2)}/${d.slice(2)}`; +}; + +/** ZIP+4 from autofill keeps the five-digit ZIP. */ +export const formatZip = (value: string): string => digitsOf(value).slice(0, 5); + +export const formatOtp = (value: string): string => digitsOf(value).slice(0, 6); + +export const MaskKind = ["Phone", "Date", "Month", "Zip", "Otp"] as const; +export type MaskKind = (typeof MaskKind)[number]; + +export const formatMask = (kind: MaskKind, value: string): string => { + switch (kind) { + case "Phone": + return formatPhone(value); + case "Date": + return formatDate(value); + case "Month": + return formatMonth(value); + case "Zip": + return formatZip(value); + case "Otp": + return formatOtp(value); + } +}; + +/** The caret index in `formatted` just after its `digitCount`-th digit. */ +export const caretAfterDigits = (formatted: string, digitCount: number): number => { + if (digitCount <= 0) return formatted.search(/\d/) === -1 ? 0 : formatted.search(/\d/); + let seen = 0; + for (let index = 0; index < formatted.length; index++) { + if (/\d/.test(formatted[index]!)) seen++; + if (seen === digitCount) return index + 1; + } + return formatted.length; +}; + +const domainTypos: Readonly> = { + "gmial.com": "gmail.com", + "gmal.com": "gmail.com", + "gmai.com": "gmail.com", + "gamil.com": "gmail.com", + "gmail.co": "gmail.com", + "gmail.con": "gmail.com", + "hotmial.com": "hotmail.com", + "hotmal.com": "hotmail.com", + "yaho.com": "yahoo.com", + "yahooo.com": "yahoo.com", + "outlok.com": "outlook.com", + "iclod.com": "icloud.com", + "icloud.co": "icloud.com", +}; + +/** A corrected address for a well-known misspelled domain, if any. */ +export const emailSuggestion = (email: string): string | undefined => { + const at = email.lastIndexOf("@"); + if (at < 1) return undefined; + const fix = domainTypos[email.slice(at + 1).toLowerCase()]; + return fix === undefined ? undefined : `${email.slice(0, at)}@${fix}`; +}; + +export const isEmail = (value: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value); + +export type CalendarDate = Readonly<{ year: number; month: number; day: number }>; + +export const parseDate = (value: string): CalendarDate | undefined => { + const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value); + if (match === null) return undefined; + const [month, day, year] = [Number(match[1]), Number(match[2]), Number(match[3])]; + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ? { year, month, day } + : undefined; +}; + +/** A MM/YYYY month as a comparable `YYYY-MM` string. */ +export const parseMonth = (value: string): string | undefined => { + const match = /^(\d{2})\/(\d{4})$/.exec(value); + if (match === null) return undefined; + const month = Number(match[1]); + return month >= 1 && month <= 12 ? `${match[2]}-${match[1]}` : undefined; +}; + +export const ageOn = (birth: CalendarDate, today: CalendarDate): number => + today.year - + birth.year - + (today.month < birth.month || (today.month === birth.month && today.day < birth.day) ? 1 : 0); diff --git a/apps/demo/src/mobile-form/inputs.styles.ts b/apps/demo/src/mobile-form/inputs.styles.ts new file mode 100644 index 0000000..157b29b --- /dev/null +++ b/apps/demo/src/mobile-form/inputs.styles.ts @@ -0,0 +1,447 @@ +import * as stylex from "@stylexjs/stylex"; + +const press = "@media (hover: none)"; +const motion = "@media (prefers-reduced-motion: no-preference)"; +const fadeIn = stylex.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }); +const slideUp = stylex.keyframes({ + from: { transform: "translateY(100%)" }, + to: { transform: "none" }, +}); +const blink = stylex.keyframes({ "0%, 100%": { opacity: 1 }, "50%": { opacity: 0 } }); + +/** Touch-first input styles: 16px+ text, 52px+ targets, and press feedback without hover. */ +export const inputStyles = stylex.create({ + field: { display: "flex", flexDirection: "column", gap: 8, scrollMarginBlock: 96 }, + labelRow: { display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8 }, + label: { fontSize: 15, fontWeight: 600, color: "var(--foreground)", lineHeight: 1.3 }, + optional: { fontSize: 14, fontWeight: 400, color: "var(--muted-foreground)" }, + control: { + boxSizing: "border-box", + width: "100%", + minHeight: 54, + paddingBlock: 0, + paddingInline: 16, + borderWidth: 1.5, + borderStyle: "solid", + borderColor: { default: "var(--input)", ":focus": "var(--primary)" }, + borderRadius: 12, + backgroundColor: "var(--background)", + color: "var(--foreground)", + // 17px matches iOS body text and keeps Safari from zooming on focus. + fontSize: 17, + fontFamily: "inherit", + lineHeight: 1.2, + outline: "none", + boxShadow: { + default: "none", + ":focus": "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)", + }, + transition: { default: null, [motion]: "border-color 120ms, box-shadow 120ms" }, + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + "::placeholder": { color: "color-mix(in oklch, var(--muted-foreground) 70%, transparent)" }, + }, + controlInvalid: { + borderColor: { default: "var(--destructive)", ":focus": "var(--destructive)" }, + boxShadow: { + default: "none", + ":focus": "0 0 0 4px color-mix(in oklch, var(--destructive) 16%, transparent)", + }, + }, + numeric: { fontVariantNumeric: "tabular-nums", letterSpacing: "0.02em" }, + date: { appearance: "none", minWidth: 0, display: "flex", alignItems: "center" }, + hint: { fontSize: 14, color: "var(--muted-foreground)", lineHeight: 1.4 }, + error: { + display: "flex", + gap: 6, + alignItems: "flex-start", + fontSize: 14, + fontWeight: 500, + color: "var(--destructive)", + lineHeight: 1.4, + }, + errorIcon: { flexShrink: 0, marginTop: 2 }, + suggestion: { + alignSelf: "flex-start", + minHeight: 40, + paddingBlock: 8, + paddingInline: 14, + borderWidth: 0, + borderRadius: 999, + backgroundColor: "color-mix(in oklch, var(--primary) 10%, var(--background))", + color: "var(--foreground)", + fontSize: 15, + fontFamily: "inherit", + textAlign: "left", + cursor: "pointer", + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + }, + fieldset: { + display: "flex", + flexDirection: "column", + gap: 10, + margin: 0, + padding: 0, + borderWidth: 0, + minWidth: 0, + scrollMarginBlock: 96, + }, + legend: { padding: 0, marginBottom: 2, fontSize: 15, fontWeight: 600 }, + srOnly: { + position: "absolute", + width: 1, + height: 1, + margin: -1, + padding: 0, + overflow: "hidden", + clipPath: "inset(50%)", + whiteSpace: "nowrap", + borderWidth: 0, + }, + card: { + position: "relative", + display: "grid", + gridTemplateColumns: "24px minmax(0, 1fr)", + gap: 14, + alignItems: "center", + minHeight: 64, + paddingBlock: 14, + paddingInline: 16, + borderWidth: 1.5, + borderStyle: "solid", + borderColor: "var(--input)", + borderRadius: 14, + backgroundColor: "var(--background)", + cursor: "pointer", + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + transform: { default: null, [press]: { default: null, ":active": "scale(0.985)" } }, + transition: { + default: null, + [motion]: "transform 90ms, border-color 120ms, background-color 120ms", + }, + ":has(input:focus-visible)": { + boxShadow: "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)", + }, + }, + cardSelected: { + borderColor: "var(--primary)", + backgroundColor: "color-mix(in oklch, var(--primary) 6%, var(--background))", + }, + radio: { + appearance: "none", + flexShrink: 0, + margin: 0, + width: 22, + height: 22, + borderRadius: "50%", + borderWidth: 2, + borderStyle: "solid", + borderColor: { default: "var(--input)", ":checked": "var(--primary)" }, + backgroundColor: { default: "var(--background)", ":checked": "var(--primary)" }, + // A background-colored ring turns the filled circle into a radio dot in both modes. + boxShadow: { default: "none", ":checked": "inset 0 0 0 4px var(--background)" }, + outline: "none", + }, + cardTitle: { fontSize: 16, fontWeight: 600, lineHeight: 1.3 }, + cardDescription: { fontSize: 14, color: "var(--muted-foreground)", lineHeight: 1.35 }, + chips: { display: "flex", flexWrap: "wrap", gap: 8 }, + chip: { + position: "relative", + display: "inline-flex", + alignItems: "center", + gap: 6, + minHeight: 44, + paddingBlock: 0, + paddingInline: 16, + borderWidth: 1.5, + borderStyle: "solid", + borderColor: "var(--input)", + borderRadius: 999, + fontSize: 15, + fontWeight: 500, + backgroundColor: "var(--background)", + cursor: "pointer", + userSelect: "none", + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + transform: { default: null, [press]: { default: null, ":active": "scale(0.96)" } }, + transition: { default: null, [motion]: "transform 90ms, background-color 120ms" }, + ":has(input:focus-visible)": { + boxShadow: "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)", + }, + }, + chipSelected: { + borderColor: "var(--primary)", + backgroundColor: "var(--primary)", + color: "var(--primary-foreground)", + }, + yesNo: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }, + yesNoOption: { + position: "relative", + display: "grid", + placeItems: "center", + minHeight: 54, + borderWidth: 1.5, + borderStyle: "solid", + borderColor: "var(--input)", + borderRadius: 14, + backgroundColor: "var(--background)", + fontSize: 17, + fontWeight: 600, + cursor: "pointer", + userSelect: "none", + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + transform: { default: null, [press]: { default: null, ":active": "scale(0.97)" } }, + transition: { + default: null, + [motion]: "transform 90ms, background-color 140ms, border-color 140ms, color 140ms", + }, + ":has(input:focus-visible)": { + boxShadow: "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)", + }, + }, + yesNoSelected: { + borderColor: "var(--primary)", + backgroundColor: "var(--primary)", + color: "var(--primary-foreground)", + }, + reveal: { + display: "grid", + gridTemplateRows: "0fr", + opacity: 0, + transition: { + default: null, + [motion]: "grid-template-rows 260ms cubic-bezier(.2,.8,.2,1), opacity 200ms ease-out", + }, + }, + revealOpen: { gridTemplateRows: "1fr", opacity: 1 }, + // Room for focus rings, which the clip would otherwise cut off. + revealClip: { minHeight: 0, overflow: "hidden", margin: -6, padding: 6 }, + // Follow-ups are indented under a rule so nested answers read as one branch. + revealBody: { + display: "flex", + flexDirection: "column", + gap: 22, + marginTop: 18, + paddingBlock: 2, + paddingLeft: 16, + borderLeftWidth: 2, + borderLeftStyle: "solid", + borderLeftColor: "var(--border)", + transform: "translateY(-8px)", + transition: { default: null, [motion]: "transform 260ms cubic-bezier(.2,.8,.2,1)" }, + }, + revealBodyOpen: { transform: "none" }, + question: { display: "flex", flexDirection: "column" }, + stepper: { + display: "grid", + gridTemplateColumns: "56px minmax(0, 1fr) 56px", + alignItems: "stretch", + minHeight: 56, + borderWidth: 1.5, + borderStyle: "solid", + borderColor: "var(--input)", + borderRadius: 14, + overflow: "hidden", + ":focus-within": { + borderColor: "var(--primary)", + boxShadow: "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)", + }, + }, + stepperButton: { + display: "grid", + placeItems: "center", + borderWidth: 0, + backgroundColor: { default: "var(--muted)", ":disabled": "var(--background)" }, + color: { default: "var(--foreground)", ":disabled": "var(--muted-foreground)" }, + cursor: "pointer", + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + transform: { default: null, [press]: { default: null, ":active": "scale(0.92)" } }, + transition: { default: null, [motion]: "transform 90ms" }, + }, + stepperValue: { + display: "flex", + alignItems: "baseline", + justifyContent: "center", + gap: 6, + }, + stepperInput: { + width: "3ch", + borderWidth: 0, + padding: 0, + backgroundColor: "transparent", + color: "var(--foreground)", + fontSize: 22, + fontWeight: 600, + fontFamily: "inherit", + fontVariantNumeric: "tabular-nums", + textAlign: "center", + outline: "none", + alignSelf: "center", + }, + stepperSuffix: { fontSize: 15, color: "var(--muted-foreground)", alignSelf: "center" }, + switchRow: { + display: "grid", + gridTemplateColumns: "minmax(0, 1fr) auto", + alignItems: "center", + gap: 16, + minHeight: 64, + paddingBlock: 12, + paddingInline: 16, + borderRadius: 14, + backgroundColor: "var(--muted)", + cursor: "pointer", + WebkitTapHighlightColor: "transparent", + }, + switch: { + appearance: "none", + position: "relative", + margin: 0, + width: 51, + height: 31, + borderRadius: 999, + backgroundColor: { default: "var(--input)", ":checked": "var(--primary)" }, + cursor: "pointer", + outline: { default: "none", ":focus-visible": "2px solid var(--ring)" }, + outlineOffset: 2, + transition: { default: null, [motion]: "background-color 160ms" }, + "::before": { + content: "''", + position: "absolute", + top: 2, + left: 2, + width: 27, + height: 27, + borderRadius: "50%", + backgroundColor: "#fff", + boxShadow: "0 2px 4px rgb(0 0 0 / 20%)", + transform: "translateX(0)", + transition: "transform 180ms cubic-bezier(.3,.7,.4,1)", + }, + ":checked::before": { transform: "translateX(20px)" }, + }, + code: { position: "relative", display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 8 }, + codeInput: { + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + margin: 0, + padding: 0, + borderWidth: 0, + opacity: 0.01, + fontSize: 16, + color: "transparent", + caretColor: "transparent", + backgroundColor: "transparent", + outline: "none", + zIndex: 1, + }, + codeCell: { + display: "grid", + placeItems: "center", + height: 60, + borderWidth: 1.5, + borderStyle: "solid", + borderColor: "var(--input)", + borderRadius: 12, + fontSize: 26, + fontWeight: 600, + fontVariantNumeric: "tabular-nums", + backgroundColor: "var(--background)", + }, + codeCellActive: { + borderColor: { + default: "var(--input)", + [stylex.when.ancestor(":focus-within")]: "var(--primary)", + }, + boxShadow: { + default: "none", + [stylex.when.ancestor(":focus-within")]: + "0 0 0 4px color-mix(in oklch, var(--primary) 18%, transparent)", + }, + }, + codeCaret: { + width: 2, + height: 28, + backgroundColor: "var(--primary)", + opacity: { default: 0, [stylex.when.ancestor(":focus-within")]: 1 }, + animationName: { default: "none", [stylex.when.ancestor(":focus-within")]: blink }, + animationDuration: "1s", + animationIterationCount: "infinite", + animationTimingFunction: "steps(1)", + }, + trigger: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 8, + textAlign: "left", + cursor: "pointer", + }, + triggerPlaceholder: { color: "color-mix(in oklch, var(--muted-foreground) 70%, transparent)" }, + sheetRoot: { + position: "fixed", + inset: 0, + width: "100%", + height: "100%", + maxWidth: "none", + maxHeight: "none", + margin: 0, + padding: 0, + borderWidth: 0, + backgroundColor: "transparent", + overflow: "hidden", + }, + sheetBackdrop: { + position: "absolute", + inset: 0, + backgroundColor: "rgb(0 0 0 / 40%)", + animationName: { default: "none", [motion]: fadeIn }, + animationDuration: "200ms", + }, + sheet: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + display: "flex", + flexDirection: "column", + gap: 12, + // A fixed height keeps the sheet from jumping while the list filters. + height: "min(72dvh, 600px)", + paddingTop: 22, + paddingInline: 16, + paddingBottom: "env(safe-area-inset-bottom)", + borderRadius: "20px 20px 0 0", + backgroundColor: "var(--popover, var(--background))", + color: "var(--popover-foreground, var(--foreground))", + boxShadow: "0 -8px 30px rgb(0 0 0 / 18%)", + animationName: { default: "none", [motion]: slideUp }, + animationDuration: "260ms", + animationTimingFunction: "cubic-bezier(.2,.9,.25,1)", + "::before": { + content: "''", + position: "absolute", + top: 8, + left: "50%", + width: 36, + height: 5, + marginLeft: -18, + borderRadius: 999, + backgroundColor: "var(--border)", + }, + }, + sheetTitle: { margin: 0, fontSize: 17, fontWeight: 600, textAlign: "center" }, + autofill: { + position: "absolute", + width: 1, + height: 1, + opacity: 0, + pointerEvents: "none", + }, +}); diff --git a/apps/demo/src/mobile-form/inputs.ts b/apps/demo/src/mobile-form/inputs.ts new file mode 100644 index 0000000..8d04497 --- /dev/null +++ b/apps/demo/src/mobile-form/inputs.ts @@ -0,0 +1,657 @@ +import * as stylex from "@stylexjs/stylex"; +import { Effect, Option, Schema as S, Stream } from "effect"; +import { Mount } from "foldkit"; +import type { Html, HtmlBuilder } from "foldkit/html"; +import { Check, ChevronDown, CircleAlert, Minus, Plus } from "@lucide/icons"; +import * as Dialog from "@foldkit/ui/dialog"; +import * as Icon from "@foldworks/ui/icon"; +import { caretAfterDigits, digitsOf, formatMask, MaskKind } from "./format"; +import { inputStyles as styles } from "./inputs.styles"; + +/** + * Stateless, touch-first form controls. Each control is one view function with + * callbacks for its Messages, so a parent adds a field with one call and owns + * its value; nothing here needs a Submodel, init, or update wiring. + */ + +const cx = (...sx: ReadonlyArray): string => + stylex.props(...sx).className ?? ""; + +/** + * Formats masked input in place before Foldkit reads it, keeping the caret + * after the same digit. Deleting a separator deletes the digit before it. + * Capture listeners on the target run before the OnInput handler. + */ +export const MaskInput = Mount.defineStream("MobileMaskInput", { + args: { kind: S.Literals(MaskKind) }, + messages: [S.Never], + execute: ({ kind, element }) => + Stream.callback(() => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => { + const input = element as HTMLInputElement; + let before = input.value; + const onBeforeInput = () => { + before = input.value; + }; + const onInput = (event: Event) => { + const raw = input.value; + const caret = input.selectionStart ?? raw.length; + let digits = digitsOf(raw); + let digitsBeforeCaret = digitsOf(raw.slice(0, caret)).length; + const deletedSeparator = + (event as InputEvent).inputType === "deleteContentBackward" && + digits === digitsOf(before) && + digitsBeforeCaret > 0; + if (deletedSeparator) { + digits = digits.slice(0, digitsBeforeCaret - 1) + digits.slice(digitsBeforeCaret); + digitsBeforeCaret -= 1; + } + const formatted = formatMask(kind, digits); + if (formatted === raw) return; + input.value = formatted; + if (document.activeElement === input) { + const position = caretAfterDigits(formatted, digitsBeforeCaret); + input.setSelectionRange(position, position); + } + }; + input.addEventListener("beforeinput", onBeforeInput, true); + input.addEventListener("input", onInput, true); + return () => { + input.removeEventListener("beforeinput", onBeforeInput, true); + input.removeEventListener("input", onInput, true); + }; + }), + (dispose) => Effect.sync(dispose), + ); + return yield* Effect.never; + }), + ), +}); + +/** + * Publishes the on-screen keyboard height as `--keyboard-inset` so a bottom + * action bar can ride above it in Safari, which does not resize the layout + * viewport. Browsers that resize content report zero. + */ +export const KeyboardInset = Mount.defineStream("MobileKeyboardInset", { + messages: [S.Never], + execute: ({ element }) => + Stream.callback(() => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => { + const viewport = window.visualViewport; + if (viewport === null) return () => {}; + const target = element as HTMLElement; + const sync = () => { + const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop); + target.style.setProperty("--keyboard-inset", `${Math.round(inset)}px`); + }; + viewport.addEventListener("resize", sync); + viewport.addEventListener("scroll", sync); + sync(); + return () => { + viewport.removeEventListener("resize", sync); + viewport.removeEventListener("scroll", sync); + }; + }), + (dispose) => Effect.sync(dispose), + ); + return yield* Effect.never; + }), + ), +}); + +const errorId = (id: string) => `${id}-error`; +const hintId = (id: string) => `${id}-hint`; + +const describedBy = ( + id: string, + error: string | undefined, + hint: string | undefined, + h: HtmlBuilder, +) => { + const ids = [ + ...(error === undefined ? [] : [errorId(id)]), + ...(hint === undefined ? [] : [hintId(id)]), + ]; + return ids.length === 0 ? [] : [h.AriaDescribedBy(ids.join(" "))]; +}; + +const errorView = (id: string, error: string | undefined, h: HtmlBuilder) => + error === undefined + ? h.empty + : h.p( + [h.Id(errorId(id)), h.Class(cx(styles.error))], + [Icon.view({ icon: CircleAlert, size: 16, sx: styles.errorIcon }, h), error], + ); + +const hintView = (id: string, hint: string | undefined, h: HtmlBuilder) => + hint === undefined ? h.empty : h.p([h.Id(hintId(id)), h.Class(cx(styles.hint))], [hint]); + +export type TextFieldConfig = Readonly<{ + id: string; + label: string; + value: string; + onInput: (value: string) => Message; + onBlur: Message; + /** Focus moves here on Enter. Omit on the last field so Enter submits the form. */ + next?: string; + error?: string | undefined; + hint?: string | undefined; + optional?: boolean; + type?: "text" | "email" | "tel" | "date"; + inputMode?: "text" | "email" | "tel" | "numeric" | "decimal" | "search" | "none"; + autocomplete: string; + autocapitalize?: "none" | "words" | "sentences" | "characters"; + spellcheck?: boolean; + placeholder?: string; + mask?: MaskKind; + maxLength?: number; + min?: string | undefined; + after?: Html | undefined; +}>; + +/** A labelled text input with the keyboard, autofill token, and Enter behavior a phone needs. */ +export const textField = (config: TextFieldConfig, h: HtmlBuilder) => + h.keyed("div")( + config.id, + [h.Class(cx(styles.field))], + [ + h.label( + [h.For(config.id), h.Class(cx(styles.labelRow))], + [ + h.span([h.Class(cx(styles.label))], [config.label]), + config.optional === true ? h.span([h.Class(cx(styles.optional))], ["Optional"]) : h.empty, + ], + ), + hintView(config.id, config.hint, h), + h.input([ + h.Id(config.id), + h.Name(config.id), + h.Type(config.type ?? "text"), + h.Value(config.value), + h.Class( + cx( + styles.control, + config.mask !== undefined && styles.numeric, + config.type === "date" && styles.date, + config.error !== undefined && styles.controlInvalid, + ), + ), + h.Autocomplete(config.autocomplete), + h.EnterKeyHint(config.next === undefined ? "done" : "next"), + ...(config.inputMode === undefined ? [] : [h.InputMode(config.inputMode)]), + ...(config.autocapitalize === undefined ? [] : [h.Autocapitalize(config.autocapitalize)]), + ...(config.spellcheck === false ? [h.Spellcheck(false), h.Autocorrect("off")] : []), + ...(config.placeholder === undefined ? [] : [h.Placeholder(config.placeholder)]), + ...(config.maxLength === undefined ? [] : [h.Maxlength(config.maxLength)]), + ...(config.min === undefined ? [] : [h.Min(config.min)]), + ...(config.optional === true ? [] : [h.AriaRequired(true)]), + h.AriaInvalid(config.error !== undefined), + ...describedBy(config.id, config.error, config.hint, h), + h.OnInput(config.onInput), + h.OnBlur(config.onBlur), + ...(config.next === undefined + ? [] + : [ + h.OnKeyDownFocus((key) => + key === "Enter" + ? Option.some({ focusSelector: `#${config.next}`, message: config.onBlur }) + : Option.none(), + ), + ]), + ...(config.mask === undefined ? [] : [h.OnMount(MaskInput({ kind: config.mask }))]), + ]), + errorView(config.id, config.error, h), + config.after ?? h.empty, + ], + ); + +export type Choice = Readonly<{ + value: Value; + label: string; + description?: string; +}>; + +/** Native radios drawn as large cards: arrow keys, labels, and form semantics stay intact. */ +export const choiceCards = ( + config: Readonly<{ + name: string; + legend: string; + options: ReadonlyArray>; + value: string; + onSelect: (value: Value) => Message; + error?: string | undefined; + }>, + h: HtmlBuilder, +) => + h.keyed("fieldset")( + config.name, + [ + h.Id(`${config.name}-group`), + h.Class(cx(styles.fieldset)), + ...describedBy(config.name, config.error, undefined, h), + ], + [ + h.legend([h.Class(cx(styles.legend))], [config.legend]), + ...config.options.map((option) => + h.label( + [h.Class(cx(styles.card, config.value === option.value && styles.cardSelected))], + [ + h.input([ + h.Id(`${config.name}-${option.value}`), + h.Type("radio"), + h.Name(config.name), + h.Value(option.value), + h.Checked(config.value === option.value), + h.Class(cx(styles.radio)), + h.OnChange(() => config.onSelect(option.value)), + ]), + h.span( + [], + [ + h.span([h.Class(cx(styles.cardTitle))], [option.label]), + option.description === undefined + ? h.empty + : h.span( + [h.Class(cx(styles.cardDescription)), h.Style({ display: "block" })], + [option.description], + ), + ], + ), + ], + ), + ), + errorView(config.name, config.error, h), + ], + ); + +/** A required yes/no question: two native radios drawn as large side-by-side buttons. */ +export const yesNo = ( + config: Readonly<{ + name: string; + legend: string; + value: "" | "yes" | "no"; + onAnswer: (answer: "yes" | "no") => Message; + error?: string | undefined; + }>, + h: HtmlBuilder, +) => + h.keyed("fieldset")( + config.name, + [ + h.Class(cx(styles.fieldset)), + h.AriaInvalid(config.error !== undefined), + ...describedBy(config.name, config.error, undefined, h), + ], + [ + h.legend([h.Class(cx(styles.legend))], [config.legend]), + h.div( + [h.Class(cx(styles.yesNo))], + (["yes", "no"] as const).map((answer) => + h.label( + [h.Class(cx(styles.yesNoOption, config.value === answer && styles.yesNoSelected))], + [ + h.input([ + h.Id(`${config.name}-${answer}`), + h.Type("radio"), + h.Name(config.name), + h.Value(answer), + h.Checked(config.value === answer), + h.Class(cx(styles.srOnly)), + h.OnChange(() => config.onAnswer(answer)), + ]), + answer === "yes" ? "Yes" : "No", + ], + ), + ), + ), + errorView(config.name, config.error, h), + ], + ); + +/** + * Shows or hides follow-up content with an animated expand and collapse. The + * content stays rendered so it can animate closed and keep its DOM state; + * while collapsed it is inert, so it cannot be focused or announced. Motion is + * pure CSS (`grid-template-rows` from 0fr to 1fr), so there is no animation + * state in the Model, and reduced-motion users get an instant change. + */ +export const reveal = ( + config: Readonly<{ id: string; isOpen: boolean; children: ReadonlyArray }>, + h: HtmlBuilder, +) => + h.keyed("div")( + config.id, + [ + h.Id(config.id), + h.Class(cx(styles.reveal, config.isOpen && styles.revealOpen)), + h.Inert(!config.isOpen), + h.AriaHidden(!config.isOpen), + ], + [ + h.div( + [h.Class(cx(styles.revealClip))], + [ + h.div( + [h.Class(cx(styles.revealBody, config.isOpen && styles.revealBodyOpen))], + config.children, + ), + ], + ), + ], + ); + +/** Multi-select pills backed by checkboxes. */ +export const chips = ( + config: Readonly<{ + name: string; + legend: string; + options: ReadonlyArray>; + selected: ReadonlyArray; + onToggle: (value: Value) => Message; + error?: string | undefined; + }>, + h: HtmlBuilder, +) => + h.keyed("fieldset")( + config.name, + [h.Class(cx(styles.fieldset)), ...describedBy(config.name, config.error, undefined, h)], + [ + h.legend([h.Class(cx(styles.legend))], [config.legend]), + h.div( + [h.Class(cx(styles.chips))], + config.options.map((option) => { + const isSelected = config.selected.includes(option.value); + return h.label( + [h.Class(cx(styles.chip, isSelected && styles.chipSelected))], + [ + h.input([ + h.Id(`${config.name}-${option.value}`), + h.Type("checkbox"), + h.Name(config.name), + h.Value(option.value), + h.Checked(isSelected), + h.Class(cx(styles.srOnly)), + h.OnChange(() => config.onToggle(option.value)), + ]), + isSelected ? Icon.view({ icon: Check, size: 16, strokeWidth: 2.5 }, h) : h.empty, + option.label, + ], + ); + }), + ), + errorView(config.name, config.error, h), + ], + ); + +/** Large minus/plus targets around a directly editable number. */ +export const stepper = ( + config: Readonly<{ + id: string; + label: string; + value: number; + min: number; + max: number; + suffix: string; + onStep: (delta: number) => Message; + onInput: (value: string) => Message; + error?: string | undefined; + }>, + h: HtmlBuilder, +) => + h.keyed("div")( + config.id, + [h.Class(cx(styles.field))], + [ + h.label([h.For(config.id), h.Class(cx(styles.label))], [config.label]), + h.div( + [h.Class(cx(styles.stepper))], + [ + h.button( + [ + h.Type("button"), + h.Class(cx(styles.stepperButton)), + h.AriaLabel("Fewer hours"), + h.AriaControls(config.id), + h.Disabled(config.value <= config.min), + h.OnClick(config.onStep(-1)), + ], + [Icon.view({ icon: Minus, size: 22 }, h)], + ), + h.div( + [h.Class(cx(styles.stepperValue))], + [ + h.input([ + h.Id(config.id), + h.Type("text"), + h.InputMode("numeric"), + h.Pattern("[0-9]*"), + h.Maxlength(2), + h.Autocomplete("off"), + h.EnterKeyHint("done"), + h.Value(config.value === 0 ? "" : String(config.value)), + h.Class(cx(styles.stepperInput)), + h.AriaInvalid(config.error !== undefined), + ...describedBy(config.id, config.error, undefined, h), + h.OnInput(config.onInput), + ]), + h.span([h.Class(cx(styles.stepperSuffix)), h.AriaHidden(true)], [config.suffix]), + ], + ), + h.button( + [ + h.Type("button"), + h.Class(cx(styles.stepperButton)), + h.AriaLabel("More hours"), + h.AriaControls(config.id), + h.Disabled(config.value >= config.max), + h.OnClick(config.onStep(1)), + ], + [Icon.view({ icon: Plus, size: 22 }, h)], + ), + ], + ), + errorView(config.id, config.error, h), + ], + ); + +/** A checkbox with the `switch` attribute, which Safari renders natively with haptics. */ +export const toggle = ( + config: Readonly<{ + id: string; + label: string; + description: string; + checked: boolean; + onToggle: (checked: boolean) => Message; + }>, + h: HtmlBuilder, +) => + h.keyed("label")( + config.id, + [h.Class(cx(styles.switchRow)), h.For(config.id)], + [ + h.span( + [], + [ + h.span([h.Class(cx(styles.cardTitle)), h.Style({ display: "block" })], [config.label]), + h.span([h.Class(cx(styles.cardDescription))], [config.description]), + ], + ), + h.input([ + h.Id(config.id), + h.Type("checkbox"), + h.Attribute("switch", ""), + h.Role("switch"), + h.Checked(config.checked), + h.Class(cx(styles.switch)), + h.OnChange(() => config.onToggle(!config.checked)), + ]), + ], + ); + +/** + * One real input drawn as six cells, so SMS autofill, paste, and the numeric + * keypad behave exactly like a native one-time-code field. + */ +export const codeInput = ( + config: Readonly<{ + id: string; + label: string; + value: string; + length: number; + onInput: (value: string) => Message; + error?: string | undefined; + }>, + h: HtmlBuilder, +) => + h.keyed("div")( + config.id, + [h.Class(cx(styles.field))], + [ + h.label([h.For(config.id), h.Class(cx(styles.srOnly))], [config.label]), + h.div( + [h.Class(stylex.props(styles.code, stylex.defaultMarker()).className ?? "")], + [ + h.input([ + h.Id(config.id), + h.Type("text"), + h.InputMode("numeric"), + h.Autocomplete("one-time-code"), + h.Pattern(`\\d{${config.length}}`), + h.Maxlength(config.length), + h.EnterKeyHint("done"), + h.Value(config.value), + h.Class(cx(styles.codeInput)), + h.AriaInvalid(config.error !== undefined), + ...describedBy(config.id, config.error, undefined, h), + h.OnInput(config.onInput), + h.OnMount(MaskInput({ kind: "Otp" })), + ]), + ...Array.from({ length: config.length }, (_, index) => + h.span( + [ + h.AriaHidden(true), + h.Class( + cx( + styles.codeCell, + index === Math.min(config.value.length, config.length - 1) && + styles.codeCellActive, + ), + ), + ], + [ + config.value[index] ?? + (index === config.value.length + ? h.span([h.Class(cx(styles.codeCaret))], []) + : ""), + ], + ), + ), + ], + ), + errorView(config.id, config.error, h), + ], + ); + +export type PickerTriggerConfig = Readonly<{ + id: string; + label: string; + displayValue: string; + placeholder: string; + onOpen: Message; + error?: string | undefined; + /** A visually hidden native select so browser autofill can still fill the value. */ + autofill: Readonly<{ + autocomplete: string; + value: string; + options: ReadonlyArray>; + onChange: (value: string) => Message; + }>; +}>; + +/** Looks like an input, opens a bottom sheet, and still participates in autofill. */ +export const pickerTrigger = ( + config: PickerTriggerConfig, + h: HtmlBuilder, +) => + h.keyed("div")( + config.id, + [h.Class(cx(styles.field)), h.Style({ position: "relative" })], + [ + h.span([h.Id(`${config.id}-label`), h.Class(cx(styles.label))], [config.label]), + h.button( + [ + h.Id(config.id), + h.Type("button"), + h.Class( + cx(styles.control, styles.trigger, config.error !== undefined && styles.controlInvalid), + ), + h.AriaHasPopup("dialog"), + h.AriaLabelledBy(`${config.id}-label ${config.id}`), + h.AriaInvalid(config.error !== undefined), + ...describedBy(config.id, config.error, undefined, h), + h.OnClick(config.onOpen), + ], + [ + h.span(config.displayValue === "" ? [h.Class(cx(styles.triggerPlaceholder))] : [], [ + config.displayValue === "" ? config.placeholder : config.displayValue, + ]), + Icon.view({ icon: ChevronDown, size: 20 }, h), + ], + ), + h.select( + [ + h.Id(`${config.id}-autofill`), + h.Name(config.id), + h.Autocomplete(config.autofill.autocomplete), + h.Tabindex(-1), + h.AriaHidden(true), + h.Class(cx(styles.autofill)), + h.OnChange(config.autofill.onChange), + ], + [ + h.option([h.Value("")], [""]), + ...config.autofill.options.map((option) => + h.option( + [h.Value(option.value), h.Selected(option.value === config.autofill.value)], + [option.label], + ), + ), + ], + ), + errorView(config.id, config.error, h), + ], + ); + +/** + * A modal bottom sheet for the headless Foldkit Dialog: focus trap, Escape, + * backdrop dismissal, scroll lock, and focus return come from the Dialog. + */ +export const bottomSheet = ( + config: Readonly<{ + title: string; + content: (initialFocus: Dialog.RenderInfo["initialFocus"]) => ReadonlyArray; + }>, + h: HtmlBuilder, +): Dialog.ViewInputs => ({ + toView: ({ dialog, backdrop, panel, title, initialFocus, isVisible }) => + h.dialog( + [...dialog, h.AriaModal(true), h.Class(cx(styles.sheetRoot))], + isVisible + ? [ + h.div([...backdrop, h.Class(cx(styles.sheetBackdrop))]), + h.section( + [...panel, h.Class(cx(styles.sheet))], + [ + h.h2([...title, h.Class(cx(styles.sheetTitle))], [config.title]), + ...config.content(initialFocus), + ], + ), + ] + : [], + ), +}); diff --git a/apps/demo/src/mobile-form/message.ts b/apps/demo/src/mobile-form/message.ts new file mode 100644 index 0000000..b5ffe81 --- /dev/null +++ b/apps/demo/src/mobile-form/message.ts @@ -0,0 +1,32 @@ +import { Schema as S } from "effect"; +import { defineMessageUnion } from "foldkit/message"; +import * as Dialog from "@foldkit/ui/dialog"; +import { CdlClass, Draft, Employment, FieldId, Shift, TextFieldId, YesNoQuestion } from "./model"; + +export const Message = defineMessageUnion({ + Initialize: {}, + Restored: { today: S.String, draft: S.NullOr(Draft) }, + ChangedText: { field: TextFieldId, value: S.String }, + LeftField: { field: FieldId }, + AcceptedEmailFix: { email: S.String }, + SelectedEmployment: { value: Employment }, + ChangedHours: { value: S.String }, + SteppedHours: { delta: S.Number }, + ToggledShift: { shift: Shift }, + ToggledAlerts: { value: S.Boolean }, + Answered: { question: YesNoQuestion, answer: S.Literals(["yes", "no"]) }, + SelectedCdlClass: { value: CdlClass }, + OpenedStateSheet: {}, + ChangedStateQuery: { value: S.String }, + ChosenState: { code: S.String }, + AutofilledState: { code: S.String }, + GotStateSheetMessage: { message: Dialog.Message }, + Submitted: {}, + PressedBack: {}, + PressedEditPhone: {}, + PressedResend: {}, + PressedStartOver: {}, + CompletedFocus: {}, + CompletedPersist: {}, +}); +export type Message = typeof Message.Type; diff --git a/apps/demo/src/mobile-form/model.ts b/apps/demo/src/mobile-form/model.ts new file mode 100644 index 0000000..7d70441 --- /dev/null +++ b/apps/demo/src/mobile-form/model.ts @@ -0,0 +1,108 @@ +import { Schema as S } from "effect"; +import * as Dialog from "@foldkit/ui/dialog"; + +export const Step = S.Literals(["About", "Address", "Work", "Background", "Verify", "Done"]); +export type Step = typeof Step.Type; +export const formSteps = ["About", "Address", "Work", "Background", "Verify"] as const; + +export const Employment = S.Literals(["full-time", "part-time", "seasonal"]); +export type Employment = typeof Employment.Type; +export const Shift = S.Literals(["mornings", "afternoons", "evenings", "overnight", "weekends"]); +export type Shift = typeof Shift.Type; +/** An unanswered question is the empty string, so "No" is a real answer. */ +export const YesNo = S.Literals(["", "yes", "no"]); +export type YesNo = typeof YesNo.Type; +export const YesNoQuestion = S.Literals(["license", "cdl", "rehire"]); +export type YesNoQuestion = typeof YesNoQuestion.Type; +export const CdlClass = S.Literals(["a", "b", "c"]); +export type CdlClass = typeof CdlClass.Type; + +/** Text inputs share one change message; their ids double as DOM ids. */ +export const TextFieldId = S.Literals([ + "first-name", + "last-name", + "email", + "phone", + "dob", + "street", + "apt", + "city", + "zip", + "start-date", + "license-number", + "previous-location", + "previous-end", + "otp", +]); +export type TextFieldId = typeof TextFieldId.Type; +export const FieldId = S.Union([ + TextFieldId, + S.Literals(["state", "employment", "hours", "shifts", "cdl-class"]), + YesNoQuestion, +]); +export type FieldId = typeof FieldId.Type; + +export const Values = S.Struct({ + text: S.Record(TextFieldId, S.String), + state: S.String, + employment: S.String, + hours: S.Number, + shifts: S.Array(Shift), + alerts: S.Boolean, + answers: S.Record(YesNoQuestion, YesNo), + cdlClass: S.String, +}); +export type Values = typeof Values.Type; + +/** What survives a reload, such as a candidate switching to Messages for the code. */ +export const Draft = S.Struct({ version: S.Literal(2), step: Step, values: Values }); +export type Draft = typeof Draft.Type; + +export const Model = S.Struct({ + step: Step, + values: Values, + /** Fields whose errors are visible: blurred once, or the step was submitted. */ + visited: S.Array(FieldId), + stateSheet: Dialog.Model, + stateQuery: S.String, + announcement: S.String, + /** ISO date read when the form loads; empty until then. */ + today: S.String, +}); +export type Model = typeof Model.Type; + +export const emptyValues: Values = { + text: { + "first-name": "", + "last-name": "", + email: "", + phone: "", + dob: "", + street: "", + apt: "", + city: "", + zip: "", + "start-date": "", + "license-number": "", + "previous-location": "", + "previous-end": "", + otp: "", + }, + state: "", + employment: "", + hours: 20, + shifts: [], + alerts: true, + answers: { license: "", cdl: "", rehire: "" }, + cdlClass: "", +}; + +export const init = (): Model => ({ + step: "About", + values: emptyValues, + visited: [], + stateSheet: Dialog.init({ id: "mobile-form-state-sheet" }), + stateQuery: "", + announcement: "", + today: "", +}); diff --git a/apps/demo/src/mobile-form/performance.ts b/apps/demo/src/mobile-form/performance.ts new file mode 100644 index 0000000..acf957a --- /dev/null +++ b/apps/demo/src/mobile-form/performance.ts @@ -0,0 +1,96 @@ +/// +import { Effect, Schema as S, Stream } from "effect"; +import { Mount } from "foldkit"; +import type { Runtime } from "foldkit"; + +/** + * A measurement probe for the mobile form. The runtime reports every Update, + * View, and Patch duration through its `slow` hook, and an input listener + * records the time from each keystroke or tap to the next painted frame. + * + * The readout writes to the DOM directly instead of dispatching Messages, so + * displaying the numbers never causes the renders being measured. + */ + +type Phase = "Update" | "View" | "Patch" | "Paint"; +const LIMIT = 400; +const samples: Record> = { Update: [], View: [], Patch: [], Paint: [] }; + +const push = (phase: Phase, ms: number) => { + const list = samples[phase]; + list.push(ms); + if (list.length > LIMIT) list.shift(); +}; + +const defaultBudgets = { Update: 4, View: 16, Patch: 8, SubscriptionDependencies: 2 } as const; + +/** Measure every phase; keep Foldkit's default slow warnings in development. */ +export const slowConfig = (): Runtime.SlowConfig => ({ + show: "Always", + measuredPhases: ["Update", "View", "Patch"], + thresholdOverrides: { Update: -1, View: -1, Patch: -1 }, + onSlow: (context) => { + if (context._tag !== "SubscriptionDependencies") push(context._tag, context.durationMs); + if (import.meta.env.DEV && context.durationMs > defaultBudgets[context._tag]) + console.warn(`[foldkit] Slow ${context._tag}: ${context.durationMs.toFixed(1)}ms`); + }, +}); + +export const percentile = (values: ReadonlyArray, p: number): number => { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]!; +}; + +const format = (ms: number): string => (ms < 1 ? `${ms.toFixed(2)} ms` : `${ms.toFixed(1)} ms`); + +/** Records input-to-next-paint for interactions inside the observed element. */ +export const Readout = Mount.defineStream("MobileFormPerformanceReadout", { + args: { scope: S.String }, + messages: [S.Never], + execute: ({ scope, element }) => + Stream.callback(() => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => { + const onInteraction = (event: Event) => { + if (!(event.target instanceof Element) || !event.target.closest(scope)) return; + const start = event.timeStamp; + requestAnimationFrame(() => + setTimeout(() => push("Paint", performance.now() - start), 0), + ); + }; + const render = () => { + for (const phase of ["Update", "View", "Patch", "Paint"] as const) { + const cell = element.querySelector(`[data-phase="${phase}"]`); + if (cell === null) continue; + const values = samples[phase]; + cell.textContent = + values.length === 0 + ? "–" + : `${format(percentile(values, 50))} · ${format(percentile(values, 95))}`; + } + const count = element.querySelector("[data-count]"); + if (count !== null) count.textContent = String(samples.Paint.length); + }; + document.addEventListener("input", onInteraction, true); + document.addEventListener("click", onInteraction, true); + const timer = setInterval(render, 500); + return () => { + document.removeEventListener("input", onInteraction, true); + document.removeEventListener("click", onInteraction, true); + clearInterval(timer); + }; + }), + (dispose) => Effect.sync(dispose), + ); + return yield* Effect.never; + }), + ), +}); + +/** Exposes raw samples so the benchmark script can read the same numbers. */ +export const exposeSamples = () => { + (globalThis as { __foldworksMobileFormSamples?: typeof samples }).__foldworksMobileFormSamples = + samples; +}; diff --git a/apps/demo/src/mobile-form/standalone.css b/apps/demo/src/mobile-form/standalone.css new file mode 100644 index 0000000..11355f1 --- /dev/null +++ b/apps/demo/src/mobile-form/standalone.css @@ -0,0 +1,21 @@ +@import "@foldworks/ui/base.css"; +@import "@foldworks/ui/themes/shadcn.css"; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + overscroll-behavior: none; +} + +body { + background: var(--background); + color: var(--foreground); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} diff --git a/apps/demo/src/mobile-form/standalone.ts b/apps/demo/src/mobile-form/standalone.ts new file mode 100644 index 0000000..7a70730 --- /dev/null +++ b/apps/demo/src/mobile-form/standalone.ts @@ -0,0 +1,27 @@ +import { Runtime } from "foldkit"; +import { Message } from "./message"; +import { init, Model } from "./model"; +import { exposeSamples, slowConfig } from "./performance"; +import { update } from "./update"; +import { formApp } from "./form-view"; +import { className, styles } from "./styles"; + +/** + * The candidate form as its own application, without the demo shell. This is + * the build the payload and interaction benchmarks measure. + */ +exposeSamples(); + +const application = Runtime.makeApplication({ + Model, + init: () => update(init(), Message.Initialize()), + update, + view: (model, h) => ({ + title: "Apply · Foldworks", + body: h.main([h.Class(className(styles.standalone))], [formApp(model, h)]), + }), + container: document.getElementById("root"), + slow: slowConfig(), +}); + +Runtime.run(application); diff --git a/apps/demo/src/mobile-form/states.ts b/apps/demo/src/mobile-form/states.ts new file mode 100644 index 0000000..1cbb3e9 --- /dev/null +++ b/apps/demo/src/mobile-form/states.ts @@ -0,0 +1,73 @@ +export type UsState = Readonly<{ code: string; name: string }>; + +export const usStates: ReadonlyArray = [ + ["AL", "Alabama"], + ["AK", "Alaska"], + ["AZ", "Arizona"], + ["AR", "Arkansas"], + ["CA", "California"], + ["CO", "Colorado"], + ["CT", "Connecticut"], + ["DE", "Delaware"], + ["DC", "District of Columbia"], + ["FL", "Florida"], + ["GA", "Georgia"], + ["HI", "Hawaii"], + ["ID", "Idaho"], + ["IL", "Illinois"], + ["IN", "Indiana"], + ["IA", "Iowa"], + ["KS", "Kansas"], + ["KY", "Kentucky"], + ["LA", "Louisiana"], + ["ME", "Maine"], + ["MD", "Maryland"], + ["MA", "Massachusetts"], + ["MI", "Michigan"], + ["MN", "Minnesota"], + ["MS", "Mississippi"], + ["MO", "Missouri"], + ["MT", "Montana"], + ["NE", "Nebraska"], + ["NV", "Nevada"], + ["NH", "New Hampshire"], + ["NJ", "New Jersey"], + ["NM", "New Mexico"], + ["NY", "New York"], + ["NC", "North Carolina"], + ["ND", "North Dakota"], + ["OH", "Ohio"], + ["OK", "Oklahoma"], + ["OR", "Oregon"], + ["PA", "Pennsylvania"], + ["RI", "Rhode Island"], + ["SC", "South Carolina"], + ["SD", "South Dakota"], + ["TN", "Tennessee"], + ["TX", "Texas"], + ["UT", "Utah"], + ["VT", "Vermont"], + ["VA", "Virginia"], + ["WA", "Washington"], + ["WV", "West Virginia"], + ["WI", "Wisconsin"], + ["WY", "Wyoming"], +].map(([code, name]) => ({ code: code!, name: name! })); + +/** Matches a state by name prefix, any word prefix, or exact postal code. */ +export const filterStates = (query: string): ReadonlyArray => { + const q = query.trim().toLowerCase(); + if (q === "") return usStates; + return usStates.filter( + (state) => + state.code.toLowerCase() === q || + state.name.toLowerCase().startsWith(q) || + state.name + .toLowerCase() + .split(" ") + .some((word) => word.startsWith(q)), + ); +}; + +export const stateName = (code: string): string => + usStates.find((state) => state.code === code)?.name ?? ""; diff --git a/apps/demo/src/mobile-form/styles.ts b/apps/demo/src/mobile-form/styles.ts new file mode 100644 index 0000000..0a77564 --- /dev/null +++ b/apps/demo/src/mobile-form/styles.ts @@ -0,0 +1,295 @@ +import * as stylex from "@stylexjs/stylex"; + +const phone = "@media (max-width: 720px)"; +const motion = "@media (prefers-reduced-motion: no-preference)"; +const press = "@media (hover: none)"; + +export const className = (...sx: ReadonlyArray): string => + stylex.props(...sx).className ?? ""; + +export const styles = stylex.create({ + page: { + display: "grid", + gridTemplateColumns: { default: "auto minmax(0, 440px)", [phone]: "minmax(0, 1fr)" }, + justifyContent: "center", + alignItems: "start", + gap: 48, + height: "100%", + minHeight: 0, + overflow: { default: "auto", [phone]: "hidden" }, + padding: { default: "32px 24px", [phone]: 0 }, + }, + device: { + width: { default: 390, [phone]: "100%" }, + height: { default: 780, [phone]: "100%" }, + borderRadius: { default: 44, [phone]: 0 }, + borderWidth: { default: 10, [phone]: 0 }, + borderStyle: "solid", + borderColor: "#111", + boxShadow: { default: "0 30px 60px -20px rgb(0 0 0 / 35%)", [phone]: "none" }, + overflow: "hidden", + // Contain fixed and sticky descendants so the desktop frame behaves like a viewport. + transform: { default: "translateZ(0)", [phone]: "none" }, + backgroundColor: "var(--background)", + }, + standalone: { height: "100dvh" }, + app: { + position: "relative", + display: "flex", + flexDirection: "column", + height: "100%", + color: "var(--foreground)", + backgroundColor: "var(--background)", + fontFamily: "var(--font-sans)", + WebkitTextSizeAdjust: "100%", + }, + top: { + display: "grid", + gridTemplateColumns: "44px minmax(0, 1fr) 44px", + alignItems: "center", + gap: 8, + paddingTop: "max(8px, env(safe-area-inset-top))", + paddingBottom: 8, + paddingInline: 8, + borderBottom: "1px solid var(--border)", + backgroundColor: "var(--background)", + }, + iconButton: { + display: "grid", + placeItems: "center", + width: 44, + height: 44, + borderWidth: 0, + borderRadius: 999, + backgroundColor: { default: "transparent", ":active": "var(--muted)" }, + color: "var(--foreground)", + cursor: "pointer", + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + }, + progress: { display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }, + progressLabel: { fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }, + progressTrack: { + width: "100%", + maxWidth: 180, + height: 4, + borderRadius: 999, + backgroundColor: "var(--muted)", + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 999, + backgroundColor: "var(--primary)", + transformOrigin: "left", + transition: { default: null, [motion]: "transform 240ms cubic-bezier(.2,.8,.2,1)" }, + }, + scroll: { + flexGrow: 1, + minHeight: 0, + overflowY: "auto", + overscrollBehavior: "contain", + WebkitOverflowScrolling: "touch", + }, + form: { + display: "flex", + flexDirection: "column", + minHeight: "100%", + }, + body: { + display: "flex", + flexDirection: "column", + gap: 22, + flexGrow: 1, + paddingTop: 24, + paddingBottom: 32, + paddingInline: 20, + }, + heading: { + margin: 0, + fontSize: 28, + fontWeight: 700, + letterSpacing: "-0.02em", + lineHeight: 1.15, + outline: "none", + scrollMarginTop: 24, + }, + lede: { + margin: 0, + marginTop: -12, + fontSize: 16, + color: "var(--muted-foreground)", + lineHeight: 1.45, + }, + pair: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }, + actions: { + position: "sticky", + bottom: "var(--keyboard-inset, 0px)", + display: "flex", + flexDirection: "column", + gap: 8, + paddingTop: 12, + paddingInline: 20, + paddingBottom: "max(12px, env(safe-area-inset-bottom))", + borderTop: "1px solid var(--border)", + backgroundColor: "color-mix(in oklch, var(--background) 92%, transparent)", + backdropFilter: "blur(12px)", + }, + primary: { + minHeight: 54, + borderWidth: 0, + borderRadius: 14, + backgroundColor: "var(--primary)", + color: "var(--primary-foreground)", + fontSize: 17, + fontWeight: 600, + fontFamily: "inherit", + cursor: "pointer", + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + transform: { default: null, [press]: { default: null, ":active": "scale(0.98)" } }, + transition: { default: null, [motion]: "transform 90ms, opacity 90ms" }, + opacity: { default: 1, ":active": 0.9 }, + }, + secondary: { + minHeight: 44, + borderWidth: 0, + backgroundColor: "transparent", + color: "var(--foreground)", + fontSize: 16, + fontWeight: 500, + fontFamily: "inherit", + textDecoration: "underline", + textUnderlineOffset: 3, + cursor: "pointer", + touchAction: "manipulation", + }, + inlineLink: { + padding: 0, + borderWidth: 0, + backgroundColor: "transparent", + color: "var(--primary)", + font: "inherit", + fontWeight: 600, + textDecoration: "underline", + textUnderlineOffset: 3, + cursor: "pointer", + }, + done: { + display: "flex", + flexDirection: "column", + alignItems: "center", + textAlign: "center", + gap: 16, + paddingTop: 48, + paddingInline: 24, + }, + doneBadge: { + display: "grid", + placeItems: "center", + width: 72, + height: 72, + borderRadius: 999, + backgroundColor: "var(--primary)", + color: "var(--primary-foreground)", + }, + summary: { + width: "100%", + margin: 0, + marginTop: 8, + padding: 0, + display: "grid", + gridTemplateColumns: "auto minmax(0, 1fr)", + gap: "10px 16px", + textAlign: "left", + fontSize: 15, + }, + summaryTerm: { color: "var(--muted-foreground)" }, + summaryValue: { margin: 0, fontWeight: 500, overflowWrap: "anywhere" }, + sheetSearch: { paddingBottom: 8 }, + searchControl: { + minHeight: 48, + paddingLeft: 42, + borderColor: { default: "transparent", ":focus": "transparent" }, + borderRadius: 999, + backgroundColor: "var(--muted)", + boxShadow: { default: "none", ":focus": "0 0 0 2px var(--ring)" }, + "::-webkit-search-cancel-button": { appearance: "none" }, + }, + searchIcon: { + position: "absolute", + left: 14, + top: "50%", + transform: "translateY(-50%)", + color: "var(--muted-foreground)", + pointerEvents: "none", + }, + sheetList: { + listStyle: "none", + margin: 0, + padding: 0, + overflowY: "auto", + overscrollBehavior: "contain", + flexGrow: 1, + minHeight: 0, + }, + sheetRow: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + width: "100%", + minHeight: 52, + paddingInline: 4, + borderWidth: 0, + borderBottom: "1px solid var(--border)", + backgroundColor: { default: "transparent", ":active": "var(--muted)" }, + color: "var(--foreground)", + fontSize: 17, + fontFamily: "inherit", + textAlign: "left", + cursor: "pointer", + touchAction: "manipulation", + WebkitTapHighlightColor: "transparent", + }, + sheetCode: { color: "var(--muted-foreground)", fontSize: 15, marginLeft: 8 }, + sheetEmpty: { padding: 16, color: "var(--muted-foreground)", textAlign: "center" }, + srOnly: { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + clipPath: "inset(50%)", + whiteSpace: "nowrap", + }, + aside: { + display: { default: "flex", [phone]: "none" }, + flexDirection: "column", + gap: 20, + paddingTop: 12, + fontSize: 14, + lineHeight: 1.55, + }, + asideTitle: { margin: 0, fontSize: 24, fontWeight: 650, letterSpacing: "-0.02em" }, + asideText: { margin: 0, color: "var(--muted-foreground)" }, + list: { margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }, + readout: { + display: "grid", + gridTemplateColumns: "auto 1fr", + gap: "6px 16px", + padding: 16, + borderRadius: 12, + border: "1px solid var(--border)", + backgroundColor: "var(--card)", + fontVariantNumeric: "tabular-nums", + }, + readoutTitle: { + gridColumn: "1 / -1", + fontWeight: 600, + display: "flex", + justifyContent: "space-between", + }, + readoutLabel: { color: "var(--muted-foreground)" }, + readoutValue: { textAlign: "right", fontFamily: "var(--font-mono, ui-monospace, monospace)" }, + readoutNote: { gridColumn: "1 / -1", fontSize: 12, color: "var(--muted-foreground)" }, + link: { color: "var(--primary)", fontWeight: 600 }, +}); diff --git a/apps/demo/src/mobile-form/update.test.ts b/apps/demo/src/mobile-form/update.test.ts new file mode 100644 index 0000000..27f688e --- /dev/null +++ b/apps/demo/src/mobile-form/update.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { Message } from "./message"; +import { init, type Model } from "./model"; +import { update } from "./update"; + +const ready = (): Model => ({ ...init(), today: "2026-10-20" }); +const apply = (model: Model, ...messages: ReadonlyArray): Model => + messages.reduce((current, message) => update(current, message).model, model); +const commandNames = (result: ReturnType) => + (result.commands ?? []).map(({ name }) => name); + +const aboutYou = [ + Message.ChangedText({ field: "first-name", value: "Ada" }), + Message.ChangedText({ field: "last-name", value: "Lovelace" }), + Message.ChangedText({ field: "email", value: "ada@example.com" }), + Message.ChangedText({ field: "phone", value: "+1 555 123 4567" }), + Message.ChangedText({ field: "dob", value: "07041990" }), +]; + +describe("mobile candidate form", () => { + it("normalizes masked fields in the model, whatever the input path", () => { + const model = apply(ready(), ...aboutYou); + + expect(model.values.text.phone).toBe("(555) 123-4567"); + expect(model.values.text.dob).toBe("07/04/1990"); + }); + + it("keeps keystrokes in memory and saves the draft when a field is left", () => { + const typed = update(ready(), Message.ChangedText({ field: "first-name", value: "A" })); + const left = update(typed.model, Message.LeftField({ field: "first-name" })); + + expect(commandNames(typed)).toEqual([]); + expect(commandNames(left)).toEqual(["PersistMobileForm"]); + expect(left.model.visited).toEqual(["first-name"]); + }); + + it("reveals every error on submit and focuses the first invalid field", () => { + const result = update( + apply(ready(), Message.ChangedText({ field: "first-name", value: "Ada" })), + Message.Submitted(), + ); + + expect(result.model.step).toBe("About"); + expect(result.model.visited).toEqual(["first-name", "last-name", "email", "phone", "dob"]); + expect(result.model.announcement).toBe("4 fields need attention."); + expect(commandNames(result)).toEqual(["FocusMobileFormField"]); + }); + + it("rejects candidates younger than sixteen", () => { + const model = apply( + ready(), + ...aboutYou, + Message.ChangedText({ field: "dob", value: "01012015" }), + Message.Submitted(), + ); + + expect(model.step).toBe("About"); + }); + + it("walks every step and submits a complete one-time code", () => { + const verify = apply( + ready(), + ...aboutYou, + Message.Submitted(), + Message.ChangedText({ field: "street", value: "1 Main Street" }), + Message.ChangedText({ field: "city", value: "Oakland" }), + Message.AutofilledState({ code: "CA" }), + Message.ChangedText({ field: "zip", value: "94612-1234" }), + Message.Submitted(), + Message.SelectedEmployment({ value: "part-time" }), + Message.SteppedHours({ delta: 50 }), + Message.ChangedText({ field: "start-date", value: "2026-11-02" }), + Message.ToggledShift({ shift: "evenings" }), + Message.Submitted(), + Message.Answered({ question: "license", answer: "no" }), + Message.Answered({ question: "rehire", answer: "no" }), + Message.Submitted(), + ); + + expect(verify.step).toBe("Verify"); + expect(verify.values.hours).toBe(60); + expect(verify.values.text.zip).toBe("94612"); + + const done = update(verify, Message.ChangedText({ field: "otp", value: "12a3456" })); + expect(done.model.values.text.otp).toBe("123456"); + expect(done.model.step).toBe("Done"); + }); + + it("validates follow-up questions only while their answer reveals them", () => { + const background: Model = { ...ready(), step: "Background" }; + const unanswered = update(background, Message.Submitted()); + expect(unanswered.model.step).toBe("Background"); + expect(unanswered.model.announcement).toBe("2 fields need attention."); + + const yes = apply( + background, + Message.Answered({ question: "license", answer: "yes" }), + Message.Answered({ question: "rehire", answer: "no" }), + Message.Answered({ question: "cdl", answer: "yes" }), + Message.Submitted(), + ); + // The license number and the nested CDL class are now required. + expect(yes.step).toBe("Background"); + expect(yes.announcement).toBe("2 fields need attention."); + + const typed = apply(yes, Message.ChangedText({ field: "license-number", value: "D1234567" })); + const no = apply(typed, Message.Answered({ question: "license", answer: "no" })); + // Hidden fields keep their values but no longer block the step. + expect(no.values.text["license-number"]).toBe("D1234567"); + expect(update(no, Message.Submitted()).model.step).toBe("Verify"); + // Answering yes again restores the previous answers, which still need a class. + const again = apply(no, Message.Answered({ question: "license", answer: "yes" })); + expect(again.values.text["license-number"]).toBe("D1234567"); + expect(update(again, Message.Submitted()).model.step).toBe("Background"); + }); + + it("requires a past month for a previous job", () => { + const answered = apply( + { ...ready(), step: "Background" }, + Message.Answered({ question: "license", answer: "no" }), + Message.Answered({ question: "rehire", answer: "yes" }), + Message.ChangedText({ field: "previous-location", value: "Oakland" }), + Message.ChangedText({ field: "previous-end", value: "122026" }), + ); + + expect(answered.values.text["previous-end"]).toBe("12/2026"); + expect(update(answered, Message.Submitted()).model.step).toBe("Background"); + const past = apply(answered, Message.ChangedText({ field: "previous-end", value: "062022" })); + expect(update(past, Message.Submitted()).model.step).toBe("Verify"); + }); + + it("rejects start dates in the past", () => { + const model = apply( + { ...ready(), step: "Work" }, + Message.SelectedEmployment({ value: "seasonal" }), + Message.ChangedText({ field: "start-date", value: "2026-10-19" }), + Message.ToggledShift({ shift: "weekends" }), + Message.Submitted(), + ); + + expect(model.step).toBe("Work"); + expect(model.visited).toContain("start-date"); + }); + + it("restores a saved draft and the current date", () => { + const draft = { + version: 2 as const, + step: "Address" as const, + values: apply(ready(), ...aboutYou).values, + }; + const model = update(init(), Message.Restored({ today: "2026-10-20", draft })).model; + + expect(model.step).toBe("Address"); + expect(model.values.text["first-name"]).toBe("Ada"); + expect(model.today).toBe("2026-10-20"); + }); +}); diff --git a/apps/demo/src/mobile-form/update.ts b/apps/demo/src/mobile-form/update.ts new file mode 100644 index 0000000..30ecb51 --- /dev/null +++ b/apps/demo/src/mobile-form/update.ts @@ -0,0 +1,257 @@ +import { Effect, Option, Schema as S } from "effect"; +import { Command, Dom, Update } from "foldkit"; +import * as Dialog from "@foldkit/ui/dialog"; +import { formatMask, type MaskKind } from "./format"; +import { Message } from "./message"; +import { + Draft, + emptyValues, + formSteps, + type FieldId, + type Model, + type Step, + type TextFieldId, + type Values, +} from "./model"; +import { invalidFields, stepFields } from "./validation"; + +type UpdateReturn = Update.Return; + +export const STORAGE_KEY = "foldworks-mobile-form"; + +const masks: Partial> = { + phone: "Phone", + dob: "Date", + "previous-end": "Month", + zip: "Zip", + otp: "Otp", +}; + +/** The DOM id that receives focus for a field, including grouped controls. */ +export const focusTarget = (field: FieldId, values: Values): string => { + switch (field) { + case "employment": + return `employment-${values.employment || "full-time"}`; + case "shifts": + return `shift-${values.shifts[0] ?? "mornings"}`; + case "license": + case "cdl": + case "rehire": + return `${field}-${values.answers[field] || "yes"}`; + case "cdl-class": + return `cdl-class-${values.cdlClass || "a"}`; + default: + return field; + } +}; + +const Restore = Command.define("RestoreMobileForm", { + messages: [Message.Restored], + execute: Effect.sync(() => { + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + try { + const json = localStorage.getItem(STORAGE_KEY); + return Message.Restored({ + today, + draft: json === null ? null : S.decodeUnknownSync(Draft)(JSON.parse(json)), + }); + } catch { + return Message.Restored({ today, draft: null }); + } + }), +}); + +const Persist = Command.define("PersistMobileForm", { + args: { draft: Draft }, + messages: [Message.CompletedPersist], + execute: ({ draft }) => + Effect.sync(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); + } catch { + /* Private browsing: the form still works for this visit. */ + } + return Message.CompletedPersist(); + }), +}); + +const Focus = Command.define("FocusMobileFormField", { + args: { id: S.String }, + messages: [Message.CompletedFocus], + execute: ({ id }) => + Dom.focus(`#${CSS.escape(id)}`, { preventScroll: true }).pipe( + Effect.andThen(Dom.scrollIntoView(`#${CSS.escape(id)}`, { block: "center" })), + Effect.ignore, + Effect.as(Message.CompletedFocus()), + ), +}); + +/** Moves to a step and puts focus on its heading so screen readers announce it. */ +const ShowStep = Command.define("ShowMobileFormStep", { + messages: [Message.CompletedFocus], + execute: Dom.focus("#mobile-form-heading", { preventScroll: true, makeFocusable: true }).pipe( + Effect.andThen( + Effect.sync(() => document.querySelector("#mobile-form-scroll")?.scrollTo(0, 0)), + ), + Effect.ignore, + Effect.as(Message.CompletedFocus()), + ), +}); + +const persist = (model: Model, commands: UpdateReturn["commands"] = []): UpdateReturn => ({ + model, + commands: [ + ...(commands ?? []), + Persist({ draft: { version: 2, step: model.step, values: model.values } }), + ], +}); + +const withValues = (model: Model, values: Partial): Model => ({ + ...model, + values: { ...model.values, ...values }, +}); + +const visit = (model: Model, fields: ReadonlyArray): Model => ({ + ...model, + visited: [...new Set([...model.visited, ...fields])], +}); + +const stepTitles: Readonly> = { + About: "About you", + Address: "Where do you live?", + Work: "Your availability", + Background: "A few more questions", + Verify: "Verify your phone", + Done: "You're all set", +}; +export const stepTitle = (step: Step): string => stepTitles[step]; + +const goTo = (model: Model, step: Step): UpdateReturn => { + const index = formSteps.indexOf(step as (typeof formSteps)[number]); + return persist( + { + ...model, + step, + announcement: + index === -1 + ? stepTitle(step) + : `Step ${index + 1} of ${formSteps.length}: ${stepTitle(step)}`, + }, + // The code field takes focus so Android opens the keypad and SMS autofill is one tap away. + [step === "Verify" ? Focus({ id: "otp" }) : ShowStep()], + ); +}; + +const submit = (model: Model): UpdateReturn => { + const invalid = invalidFields(model.step, model.values, model.today); + const first = invalid[0]; + if (first !== undefined) { + return { + model: { + ...visit(model, stepFields[model.step]), + announcement: `${invalid.length} ${invalid.length === 1 ? "field needs" : "fields need"} attention.`, + }, + commands: [Focus({ id: focusTarget(first, model.values) })], + }; + } + const next = formSteps[formSteps.indexOf(model.step as (typeof formSteps)[number]) + 1]; + return goTo(model, next ?? "Done"); +}; + +const foldStateSheet = Update.foldChild({ + update: Dialog.update, + read: (model: Model) => Option.some(model.stateSheet), + write: (model, stateSheet) => ({ ...model, stateSheet }), + toParentMessage: (message) => Message.GotStateSheetMessage({ message }), + foldOutMessage: (_message: Dialog.OutMessage) => (model: Model) => ({ model }), +}); + +export const update = (model: Model, message: Message): UpdateReturn => + Message.match(message, { + Initialize: () => ({ model, commands: [Restore()] }), + Restored: ({ today, draft }) => + draft === null + ? { model: { ...model, today } } + : { model: { ...model, today, step: draft.step, values: draft.values } }, + ChangedText: ({ field, value }) => { + const mask = masks[field]; + const next = withValues(model, { + text: { + ...model.values.text, + [field]: mask === undefined ? value : formatMask(mask, value), + }, + }); + // One-time codes submit themselves once complete, matching SMS autofill. + // Keystrokes stay in memory; the draft is written when the field is left. + return field === "otp" && next.values.text.otp.length === 6 && model.step === "Verify" + ? submit(next) + : { model: next }; + }, + // Leaving a field saves the draft. Switching apps blurs the field, so a candidate + // who opens Messages for the code keeps everything they typed. + LeftField: ({ field }) => persist(visit(model, [field])), + AcceptedEmailFix: ({ email }) => + persist(withValues(model, { text: { ...model.values.text, email } }), [ + Focus({ id: "email" }), + ]), + SelectedEmployment: ({ value }) => + persist(visit(withValues(model, { employment: value }), ["employment"])), + ChangedHours: ({ value }) => { + const hours = Number(value.replace(/\D/g, "").slice(0, 2)); + return persist(withValues(model, { hours: Number.isFinite(hours) ? hours : 0 })); + }, + SteppedHours: ({ delta }) => + persist(withValues(model, { hours: Math.min(60, Math.max(1, model.values.hours + delta)) })), + ToggledShift: ({ shift }) => + persist( + visit( + withValues(model, { + shifts: model.values.shifts.includes(shift) + ? model.values.shifts.filter((value) => value !== shift) + : [...model.values.shifts, shift], + }), + ["shifts"], + ), + ), + ToggledAlerts: ({ value }) => persist(withValues(model, { alerts: value })), + Answered: ({ question, answer }) => + persist( + visit(withValues(model, { answers: { ...model.values.answers, [question]: answer } }), [ + question, + ]), + ), + SelectedCdlClass: ({ value }) => + persist(visit(withValues(model, { cdlClass: value }), ["cdl-class"])), + OpenedStateSheet: () => + foldStateSheet({ ...model, stateQuery: "" }, Dialog.Message.RequestedOpen()), + ChangedStateQuery: ({ value }) => ({ model: { ...model, stateQuery: value } }), + ChosenState: ({ code }) => { + const closed = foldStateSheet( + visit(withValues(model, { state: code }), ["state"]), + Dialog.Message.RequestedClose(), + ); + return persist(closed.model, closed.commands); + }, + AutofilledState: ({ code }) => persist(withValues(model, { state: code })), + GotStateSheetMessage: ({ message: child }) => foldStateSheet(model, child), + Submitted: () => submit(model), + PressedBack: () => { + const index = formSteps.indexOf(model.step as (typeof formSteps)[number]); + return index > 0 ? goTo(model, formSteps[index - 1]!) : { model }; + }, + PressedEditPhone: () => + persist( + { ...model, step: "About", announcement: `Step 1 of ${formSteps.length}: About you` }, + [Focus({ id: "phone" })], + ), + PressedResend: () => ({ + model: { + ...withValues(model, { text: { ...model.values.text, otp: "" } }), + announcement: "Code sent. It can take up to a minute to arrive.", + }, + }), + PressedStartOver: () => goTo({ ...model, values: emptyValues, visited: [] }, "About"), + CompletedFocus: () => ({ model }), + CompletedPersist: () => ({ model }), + }); diff --git a/apps/demo/src/mobile-form/validation.ts b/apps/demo/src/mobile-form/validation.ts new file mode 100644 index 0000000..6da2e59 --- /dev/null +++ b/apps/demo/src/mobile-form/validation.ts @@ -0,0 +1,128 @@ +import type { FieldId, Step, Values } from "./model"; +import { ageOn, emailSuggestion, isEmail, parseDate, parseMonth, phoneDigits } from "./format"; + +/** Fields in visual order; Enter advances through them and errors focus the first. */ +export const stepFields: Readonly>> = { + About: ["first-name", "last-name", "email", "phone", "dob"], + Address: ["street", "apt", "city", "state", "zip"], + Work: ["employment", "hours", "start-date", "shifts"], + Background: [ + "license", + "license-number", + "cdl", + "cdl-class", + "rehire", + "previous-location", + "previous-end", + ], + Verify: ["otp"], + Done: [], +}; + +/** + * Follow-up questions exist only while the answer above them is "yes". Hidden + * fields keep their values, so answering "yes" again restores them, but they + * are never validated or required. + */ +export const isShown = (field: FieldId, values: Values): boolean => { + switch (field) { + case "license-number": + case "cdl": + return values.answers.license === "yes"; + case "cdl-class": + return values.answers.license === "yes" && values.answers.cdl === "yes"; + case "previous-location": + case "previous-end": + return values.answers.rehire === "yes"; + default: + return true; + } +}; + +const required = (value: string, message: string) => (value.trim() === "" ? message : undefined); + +export const fieldError = (field: FieldId, values: Values, today: string): string | undefined => { + const text = values.text; + if (!isShown(field, values)) return undefined; + switch (field) { + case "first-name": + return required(text["first-name"], "Enter your first name."); + case "last-name": + return required(text["last-name"], "Enter your last name."); + case "email": + return ( + required(text.email, "Enter your email address.") ?? + (isEmail(text.email.trim()) ? undefined : "Enter an email like name@example.com.") + ); + case "phone": + return phoneDigits(text.phone).length === 10 ? undefined : "Enter a 10-digit mobile number."; + case "dob": { + const birth = parseDate(text.dob); + if (birth === undefined) return "Enter a real date as MM/DD/YYYY."; + const now = parseIso(today); + if (now === undefined) return undefined; + const age = ageOn(birth, now); + return age < 16 + ? "You must be at least 16 to apply." + : age > 100 + ? "Check the year you were born." + : undefined; + } + case "street": + return required(text.street, "Enter your street address."); + case "apt": + return undefined; + case "city": + return required(text.city, "Enter your city."); + case "state": + return values.state === "" ? "Choose your state." : undefined; + case "zip": + return /^\d{5}$/.test(text.zip) ? undefined : "Enter a 5-digit ZIP code."; + case "employment": + return values.employment === "" ? "Choose how you want to work." : undefined; + case "hours": + return values.hours >= 1 && values.hours <= 60 ? undefined : "Choose 1 to 60 hours."; + case "start-date": + return text["start-date"] === "" + ? "Choose your earliest start date." + : today !== "" && text["start-date"] < today + ? "Choose today or a later date." + : undefined; + case "shifts": + return values.shifts.length === 0 ? "Choose at least one shift." : undefined; + case "license": + case "cdl": + case "rehire": + return values.answers[field] === "" ? "Choose yes or no." : undefined; + case "license-number": + return required(text["license-number"], "Enter your license number."); + case "cdl-class": + return values.cdlClass === "" ? "Choose a CDL class." : undefined; + case "previous-location": + return required(text["previous-location"], "Enter the location."); + case "previous-end": { + const month = parseMonth(text["previous-end"]); + return month === undefined + ? "Enter a month as MM/YYYY." + : today !== "" && month > today.slice(0, 7) + ? "Choose a month that has passed." + : undefined; + } + case "otp": + return /^\d{6}$/.test(text.otp) ? undefined : "Enter the 6-digit code."; + } +}; + +export const invalidFields = (step: Step, values: Values, today: string): ReadonlyArray => + stepFields[step].filter((field) => fieldError(field, values, today) !== undefined); + +/** Only offer a correction once the address otherwise looks complete. */ +export const emailFix = (email: string): string | undefined => + isEmail(email.trim()) ? emailSuggestion(email.trim()) : undefined; + +const parseIso = (value: string) => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + return match === null + ? undefined + : { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }; +}; diff --git a/apps/demo/src/mobile-form/view.ts b/apps/demo/src/mobile-form/view.ts new file mode 100644 index 0000000..c6fed65 --- /dev/null +++ b/apps/demo/src/mobile-form/view.ts @@ -0,0 +1,78 @@ +import type { Html, HtmlBuilder } from "foldkit/html"; +import { defineView } from "foldkit/submodel"; +import { formApp } from "./form-view"; +import type { Message } from "./message"; +import type { Model } from "./model"; +import { Readout } from "./performance"; +import { className, styles } from "./styles"; + +type H = HtmlBuilder; + +const readoutRow = (label: string, phase: string, h: H): ReadonlyArray => [ + h.span([h.Class(className(styles.readoutLabel))], [label]), + h.span([h.Class(className(styles.readoutValue)), h.DataAttribute("phase", phase)], ["–"]), +]; + +const aside = (h: H): Html => + h.aside( + [h.Class(className(styles.aside))], + [ + h.h2([h.Class(className(styles.asideTitle))], ["Mobile inputs"]), + h.p( + [h.Class(className(styles.asideText))], + [ + "A candidate application built from stateless Foldkit controls tuned for phones. Each control is one view call with its own Messages; only the state picker uses a Submodel.", + ], + ), + h.ul( + [h.Class(className(styles.list))], + [ + "17px text and 52px+ targets, so Safari never zooms and thumbs never miss.", + "The right keyboard per field, Enter moves to the next field, and the last one submits.", + "Autofill tokens on every field, including a hidden select behind the state sheet.", + "Phone, date, and ZIP masks that keep the caret in place and accept autofilled values.", + "One real one-time-code input drawn as six cells, so SMS autofill and paste work.", + "Errors appear after you leave a field, then clear as soon as it's fixed.", + "Yes/no questions reveal nested follow-ups with an animated expand. Hidden answers are kept but never validated.", + "The action bar rides above the keyboard and respects the home indicator.", + "Drafts survive a reload, such as switching to Messages for the code.", + ].map((item) => h.li([], [item])), + ), + h.div( + [h.Class(className(styles.readout)), h.OnMount(Readout({ scope: "#mobile-form-device" }))], + [ + h.div( + [h.Class(className(styles.readoutTitle))], + [ + h.span([], ["Live timings (p50 · p95)"]), + h.span( + [h.Class(className(styles.readoutLabel))], + [h.span([h.DataAttribute("count", "")], ["0"]), " samples"], + ), + ], + ), + ...readoutRow("Update", "Update", h), + ...readoutRow("View", "View", h), + ...readoutRow("Patch", "Patch", h), + ...readoutRow("Input → next paint", "Paint", h), + h.p( + [h.Class(className(styles.readoutNote))], + [ + "Measured in this tab for the whole demo application, including every other demo's state. Type in the form to collect samples.", + ], + ), + ], + ), + h.a([h.Href("/apply.html"), h.Class(className(styles.link))], ["Open the standalone form →"]), + ], + ); + +export const view = defineView((model, h) => + h.div( + [h.Class(className(styles.page))], + [ + h.div([h.Id("mobile-form-device"), h.Class(className(styles.device))], [formApp(model, h)]), + aside(h), + ], + ), +); diff --git a/apps/demo/vite.mobile-form.config.ts b/apps/demo/vite.mobile-form.config.ts new file mode 100644 index 0000000..c441848 --- /dev/null +++ b/apps/demo/vite.mobile-form.config.ts @@ -0,0 +1,21 @@ +import { foldkit } from "@foldkit/vite-plugin"; +import { foldworksLayers } from "@foldworks/ui/vite"; +import stylex from "@stylexjs/unplugin"; +import { defineConfig } from "vite"; + +/** Builds the mobile candidate form alone so its payload is measured without the demo shell. */ +export default defineConfig(({ mode }) => ({ + plugins: [ + stylex.vite({ + dev: mode === "development", + runtimeInjection: false, + useCSSLayers: { before: foldworksLayers }, + }), + foldkit(), + ], + build: { + emptyOutDir: false, + assetsDir: "apply", + rollupOptions: { input: "apply.html" }, + }, +})); diff --git a/docs/mobile-inputs.md b/docs/mobile-inputs.md new file mode 100644 index 0000000..89ad421 --- /dev/null +++ b/docs/mobile-inputs.md @@ -0,0 +1,121 @@ +# Mobile inputs + +A candidate application form built to feel native on a phone, and a benchmark +that compares it with the same form written in React. The demo lives at +`/mobile-form`. A standalone build of only the form is at `/apply.html`. + +## What the form does + +The form has five steps: about you, address, availability, a few more +questions, and phone verification. It uses stateless controls from `apps/demo/src/mobile-form/inputs.ts`. Each +control is a single view function that takes callbacks for its Messages, so a +parent adds a field with one call and owns the value. Only the state picker +uses a Submodel, which wraps the headless Foldkit Dialog. + +| Concern | Behavior | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Zoom and targets | Inputs use 17px text, so iOS Safari never zooms on focus. Controls are at least 52px tall and chips at least 44px. Taps give press feedback through `:active` on touch devices only. | +| Keyboards | Each field sets `inputmode`, `type`, `autocapitalize`, and `spellcheck` to suit its content. Email turns off autocorrect, and numeric fields open the keypad. | +| Enter key | `enterkeyhint="next"` moves focus synchronously inside the key event, so the iOS keyboard stays open. The last field of a step shows "done" and submits through the form. | +| Autofill | Every field has an `autocomplete` token. The state sheet keeps a hidden, focusable `