import { type ComponentPropsWithRef, type KeyboardEvent, type ReactNode, type Ref, useImperativeHandle, useLayoutEffect, useState, } from "react"; import { Button } from "../../ui/button"; import { cn } from "../../lib/utils"; import { isAnswered } from "./question-state"; export type QuestionItem = { id: string; header: string; question: string; options: { id: string; label: string; description: string }[]; multiple: boolean; }; export type QuestionDraft = Record; custom: string; }>; export type QuestionForm = { definition: { questions: QuestionItem[] }; view?: QuestionDraft; disabled: boolean; submitting: boolean; syncing?: boolean; error?: string; validation?: string; status?: string; actions?: ReactNode; choose: (id: string, option: string, multiple: boolean, checked: boolean) => void; focus?: (id: string, field: "choices" | "custom") => void; blur?: () => void; submit: () => void | Promise; retry?: () => void; custom?: (question: QuestionItem) => ReactNode; presence?: (id: string) => { count: number; content: ReactNode } | undefined; }; export type QuestionViewHandle = { invalid: (id: string) => void }; export type QuestionViewProps = Omit, "id" | "title"> & { id: string; count: number; requester: ReactNode; title?: string; form: QuestionForm; handle?: Ref; }; /** Controlled questionnaire presentation; transport and shared editing stay in adapters. */ export function QuestionView( { id, count, requester, title = "Ace needs your input", form, handle, className, ...props }: QuestionViewProps, ) { let [selected, setActive] = useState(); let [focusRequest, setFocusRequest] = useState<{ id: string; target: "tab" | "answer"; }>(); function invalid(id: string) { setActive(id); setFocusRequest({ id, target: "tab" }); } let { definition, view, syncing, blur } = form; useImperativeHandle(handle, () => ({ invalid })); let active = definition.questions.some(question => question.id === selected) ? selected : definition.questions[0]?.id; useLayoutEffect(() => { if (!active) return; let target = focusRequest?.id === active ? focusRequest.target : undefined; let tab = document.getElementById(tabId(active)); tab?.scrollIntoView({ block: "nearest", inline: "nearest" }); if (target === "tab") tab?.focus(); else if (target === "answer") { let panel = document.getElementById(panelId()); let options = panel ? [ ...panel.querySelectorAll( 'input[type="radio"], input[type="checkbox"]', ), ].filter(input => !input.disabled) : []; (options.find(input => input.checked) ?? options[0])?.focus(); } if (target) setFocusRequest(undefined); }, [active, focusRequest, id]); let questions = definition.questions; let index = Math.max(0, questions.findIndex(question => question.id === active)); let question = questions[index]; let tabs = questions.length > 1; function tabId(question: string) { return `question-tab-${id}-${question}`; } function panelId() { return `question-panel-${id}`; } function select(next: number, moveFocus?: "tab" | "answer") { let question = questions[next]; if (!question) return; blur?.(); setFocusRequest(moveFocus ? { id: question.id, target: moveFocus } : undefined); setActive(question.id); } function navigate(event: KeyboardEvent) { if ( event.defaultPrevented || event.nativeEvent.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ) { return; } let target = event.target; if (!(target instanceof HTMLElement)) return; let tab = target.closest('[role="tab"]'); let choice = target instanceof HTMLInputElement && (target.type === "radio" || target.type === "checkbox") ? target : undefined; if (!tab && !choice) return; if ((choice || tab) && (event.key === "ArrowUp" || event.key === "ArrowDown")) { let panel = choice ? target.closest('[role="tabpanel"]') ?? target.closest("fieldset") : document.getElementById(panelId()); let options = panel ? [ ...panel.querySelectorAll( 'input[type="radio"], input[type="checkbox"]', ), ] .filter(input => !input.disabled) : []; if (options.length === 0) return; let current = choice ? options.indexOf(choice) : options.findIndex(input => input.checked); let offset = event.key === "ArrowDown" ? 1 : -1; let next = current === -1 ? options[event.key === "ArrowDown" ? 0 : options.length - 1] : options[(current + offset + options.length) % options.length]; event.preventDefault(); event.stopPropagation(); next.focus(); if (next.type === "radio") next.click(); return; } if (!tabs) return; let next: number | undefined; if (event.key === "ArrowRight") next = (index + 1) % questions.length; else if (event.key === "ArrowLeft") next = (index - 1 + questions.length) % questions.length; else if (event.key === "Home" && tab) next = 0; else if (event.key === "End" && tab) next = questions.length - 1; if (next === undefined) return; event.preventDefault(); event.stopPropagation(); select(next, choice ? "answer" : "tab"); } let extra = count > 1 ? count - 1 : 0; return (

{title}

Requested by {requester} {extra > 0 && ` · ${extra} more pending`}

{syncing && Syncing…}
{tabs && ( )}
{question && (
)}
); } function QuestionFooter( { form, index, select }: { form: QuestionForm; index: number; select: (index: number) => void; }, ) { let { error, validation, status, retry, submitting, disabled, submit } = form; let questions = form.definition.questions; let tabs = questions.length > 1; let last = index === questions.length - 1; return (

{error || validation || status || (tabs ? `Question ${index + 1} of ${questions.length}` : "")}

{form.actions} {error && retry && ( )} {tabs && index > 0 && ( )} {tabs && !last ? ( ) : ( )}
); } function QuestionAnswer( { form, question, index, item }: { form: QuestionForm; question: QuestionItem; index: number; item: string; }, ) { let { disabled, view, focus, blur, choose } = form; let answer = view?.[question.id]; let presence = form.presence?.(question.id); return (
{index + 1}. {question.header} {presence && (
{presence.content}
)}

{question.question}

{question.options.map((option) => { let checked = answer?.mode === "choices" && (question.multiple ? !!answer.options?.[option.id] : answer.choice === option.id); return ( ); })}
{form.custom?.(question)}
); } function QuestionTabs({ questions, index, view, presence, tabId, panelId, select }: { questions: QuestionItem[]; index: number; view?: QuestionDraft; presence?: QuestionForm["presence"]; tabId: (id: string) => string; panelId: () => string; select: (index: number) => void; }) { return (
{questions.map((item, tab) => { let selected = tab === index; let complete = isAnswered(item, view?.[item.id]); let present = presence?.(item.id)?.count || 0; let editing = present ? `, ${present} ${present === 1 ? "collaborator" : "collaborators"} editing` : ""; return ( ); })}
); }