/** * The focused questionnaire view. * * Unanswered cards stay in document order, because the first is where a room * resumes its work. Resolved history starts closed: it remains available without * making a long-lived room open below the questions that still need an answer. */ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react"; import { cardStatus } from "@chopin/dialect"; import { ChevronIcon, DecisionIcon } from "@chopin/icons"; import { EmptyState } from "@chopin/visuals"; import { MotionDisclosure, MotionDisclosureIcon } from "./disclosure-motion"; import { useQuestionnaires } from "./questionnaires"; import { QuestionnaireCard } from "./widgets/questionnaire"; import type { Question } from "@chopin/protocol"; import type { ReactNode } from "react"; import type { CardMetaStore } from "./card-meta"; import type { Transport } from "@chopin/question/react"; import type { MotionDisclosureContract } from "./disclosure-motion"; import type { QuestionnaireEntry, QuestionnaireStore } from "./questionnaires"; import type { QuestionStepMotion } from "./widget-options"; export type DecisionsProps = { additional?: ReactNode; hasAdditional?: boolean; store: QuestionnaireStore; cardMeta?: CardMetaStore; canEdit?: boolean; /** The viewer's own handle, who is never shown as present on a card. */ self?: string; /** False when no Planner will review where decisions live. */ planner?: boolean; motion: MotionDisclosureContract; motionImmediately?: () => boolean; questionMotion?: QuestionStepMotion; wire?: Transport; connected?: boolean; headingId?: string; /** Reveal the plan before taking the reader to a questionnaire's result. */ onShowPlan?: (widget: string, question: string) => void; /** * An item to bring into view. * * Carries a token as well as an id so asking for the same one twice still * scrolls — naming it again means "show me", not "it is already open". */ reveal?: { widget: string; token: number }; }; let EMPTY_CARDS: ReadonlyMap = new Map(); function noop() { return () => {}; } function emptyCards() { return EMPTY_CARDS; } function status(entry: QuestionnaireEntry, cardMeta: ReadonlyMap) { return cardMeta.get(entry.id)?.status ?? cardStatus(entry.value); } function waiting(entry: QuestionnaireEntry, cardMeta: ReadonlyMap) { let current = status(entry, cardMeta); return current === "open" || current === "reopened"; } let HISTORY = "chopin:decisions:resolved"; /** Resolved history stays closed unless the reader explicitly opened it. */ function useHistory() { let [history, setHistory] = useState(() => localStorage.getItem(HISTORY) === "true"); useEffect(() => { localStorage.setItem(HISTORY, String(history)); }, [history]); return [history, setHistory] as const; } export function Decisions( { additional, hasAdditional = false, cardMeta, canEdit = true, connected, headingId, motion, motionImmediately, onShowPlan, planner, questionMotion, reveal, self, store, wire, }: DecisionsProps, ) { let entries = useQuestionnaires(store); let readiness = useSyncExternalStore( store.subscribe, store.readinessSnapshot, store.readinessSnapshot, ); let metadata = useSyncExternalStore( cardMeta?.subscribe ?? noop, cardMeta?.snapshot ?? emptyCards, cardMeta?.snapshot ?? emptyCards, ); let content = useRef(null); let heading = useRef(null); let focusedQuestionnaire = useRef(undefined); let revealed = useRef(undefined); let [history, setHistory] = useHistory(); let historyId = useId(); // Leaving the pane should not leave the prose lit. A highlight belongs to // the pointer that asked for it, and a pin to the pane that set it. useEffect(() => () => { store.release(); }, [store]); useEffect(() => { if (!reveal || revealed.current === reveal.token) return; revealed.current = reveal.token; let id = CSS.escape(reveal.widget); let target = content.current?.querySelector( `[data-plan-sidecar-questionnaire="${id}"]`, ); if (target) { target.scrollIntoView({ block: "center" }); target.tabIndex = -1; target.focus({ preventScroll: true }); } else heading.current?.focus(); }, [entries, metadata, reveal]); // Removing a focused card sends focus to body without a blur event. Remember // the actual card so its removal can hand focus to the remaining work. useEffect(() => { let previous = focusedQuestionnaire.current; if (!previous || previous.isConnected || document.activeElement !== document.body) return; focusedQuestionnaire.current = undefined; let next = entries.find(entry => waiting(entry, metadata)); let target = next ? content.current?.querySelector( `[data-plan-sidecar-questionnaire="${CSS.escape(next.id)}"]`, ) : undefined; if (target) { target.tabIndex = -1; target.focus({ preventScroll: true }); } else heading.current?.focus({ preventScroll: true }); }, [entries, metadata]); let pending = entries.filter(entry => waiting(entry, metadata)); let settled = entries.filter(entry => !waiting(entry, metadata)); let outstanding = pending.length; let resolved = settled.length; let empty = outstanding === 0 && resolved === 0 && !hasAdditional; let question = (entry: QuestionnaireEntry) => ( store.highlight(entry.id, question)} onQuestionLeave={() => store.clear()} onQuestionSelect={question => { if (onShowPlan) onShowPlan(entry.id, question); else store.reveal(entry.id, question); }} places={store.counts(entry.id)} presentation="list" self={self} value={entry.value} wire={wire} /> ); return (

Decisions

{ let next = event.relatedTarget; if (next instanceof Node && !event.currentTarget.contains(next)) { focusedQuestionnaire.current = undefined; } }} onFocusCapture={event => { focusedQuestionnaire.current = (event.target as HTMLElement).closest( "[data-plan-sidecar-questionnaire]", ) ?? undefined; }} ref={content} > {additional} {empty ? ( <> {readiness === "ready" ? "No decisions yet" : readiness === "unavailable" ? "Decisions unavailable" : "Loading decisions"}
); }