/** * The questionnaire surface. * * Transport-free by design: it takes a definition, the current draft and a set * of callbacks. That is what lets the same view serve the chat card, where the * draft is a live CRDT, and a plan widget, where it is a resolved record. * * Everyone edits one shared draft, so controls reflect other people's choices * as they arrive rather than tracking local state. */ import { useCallback, useEffect, useId, useRef, useState } from "react"; import { CheckIcon, ShuffleSimpleIcon, XIcon } from "@phosphor-icons/react"; import { answered } from "../draft"; import type { KeyboardEvent, ReactNode } from "react"; import type { Draft, Drafts } from "../draft"; import type { Answer, Definition, Item } from "../schema"; export type Collaborator = { /** Stable per connection, so one person on two devices shows twice. */ client: string; handle: string; question?: string; }; export type QuestionViewProps = { definition: Definition; drafts: Drafts; /** Absent once resolved: a decision is not re-opened, a new question is asked. */ onChange?: (question: string, change: Partial) => void; onSubmit?: () => void; onCancel?: () => void; disabled?: boolean; submitting?: boolean; status?: "open" | "answered" | "cancelled"; /** Shown instead of controls once the questionnaire has resolved. */ answers?: Answer[]; resolver?: string; onQuestionEnter?: (question: string) => void; onQuestionLeave?: (question: string) => void; /** Goes to the prose the decision lives in. */ onQuestionSelect?: (question: string) => void; /** * How many passages each decision lives in, by question. * * Absent where nothing links the two — the chat card, or a decision still * waiting to be anchored. Without a destination the text stays inert prose * rather than advertising a jump that would do nothing. */ places?: Record; collaborators?: Collaborator[]; /** Validation or synchronisation problem, announced to assistive tech. */ error?: string; /** Rendered beside the heading; hosts use it for counts and provenance. */ aside?: ReactNode; }; export function currentQuestion( definition: Definition, active: string | undefined, ): Item { return definition.questions.find(question => question.id === active) ?? definition.questions[0]!; } function Badges({ people }: { people: Collaborator[] }) { if (people.length === 0) return null; return ( {people.map(person => ( @{person.handle} ))} ); } function DecisionHeading() { return (
); } function Choices( { question, draft, disabled, name, onChange }: { question: Item; draft: Draft | undefined; disabled: boolean; name: string; onChange?: (change: Partial) => void; }, ) { let custom = draft?.mode === "custom"; return (
{question.header} {question.options.map(option => { let selected = question.multiple ? !!draft?.options[option.id] : draft?.choice === option.id; return ( ); })}
); } function Custom( { question, draft, disabled, name, onChange }: { question: Item; draft: Draft | undefined; disabled: boolean; name: string; onChange?: (change: Partial) => void; }, ) { let active = draft?.mode === "custom"; let textarea = useRef(null); let focusOnReveal = useRef(false); useEffect(() => { if (!active || !focusOnReveal.current) return; focusOnReveal.current = false; textarea.current?.focus(); }, [active]); useEffect(() => { let viewport = window.visualViewport; if (!viewport) return; let height = viewport.height; let reveal = () => { let previous = height; height = viewport.height; let control = textarea.current; if (height >= previous || document.activeElement !== control || !control) return; let bounds = control.getBoundingClientRect(); let top = viewport.offsetTop; let bottom = top + viewport.height; if (bounds.top >= top && bounds.bottom <= bottom) return; requestAnimationFrame(() => control.scrollIntoView({ block: "nearest" })); }; viewport.addEventListener("resize", reveal); return () => viewport.removeEventListener("resize", reveal); }, []); return (
{active && (