-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Questionnaire v2: fill outline as an overlay rail + panel #16629
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,35 @@ | ||
| import { CheckCheck, Dot } from "lucide-react"; | ||
| import { useTranslation } from "react-i18next"; | ||
|
|
||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| import { | ||
| useAnsweredQuestionIds, | ||
| useFormRenderer, | ||
| useHiddenQuestionIds, | ||
| } from "@/components/QuestionnaireV2/form/FormContext"; | ||
| import { QuestionTreeNav } from "@/components/QuestionnaireV2/shared/QuestionTreeNav"; | ||
| import type { TreeItem } from "@/components/QuestionnaireV2/shared/questionTree"; | ||
| import { | ||
| findFirstQuestion, | ||
| findTopLevelIndex, | ||
| numberQuestions, | ||
| } from "@/components/QuestionnaireV2/shared/questionTree"; | ||
|
|
||
| import type { Question } from "@/types/questionnaire/question"; | ||
|
|
||
| import { useFillOutlineNav } from "./FillOutlineOverlay"; | ||
|
|
||
| /** | ||
| * The fill page's left outline (≥lg only): the shared tree nav with live | ||
| * completion adornments — answered questions get the double-check, open | ||
| * ones a dot — and enable_when-hidden rows dropped, exactly like the | ||
| * canvas. Selecting a row scrolls its block into view via the renderer's | ||
| * `data-question-id` anchors. | ||
| * One form's rows inside the outline overlay panel, per the reference: | ||
| * numbered rows with live completion adornments — answered questions get | ||
| * the double-check, open ones a dot — the question currently in view in | ||
| * indigo with a right-edge bar (scroll-spy via `useFillOutlineNav`), and | ||
| * enable_when-hidden rows dropped, exactly like the canvas. Selecting a | ||
| * row scrolls its block into view via the renderer's `data-question-id` | ||
| * anchors. Group children indent behind a connector line. | ||
| * | ||
| * `ariaLabel` names the nav landmark: a multi-questionnaire session | ||
| * renders one outline per form into the same aside, and repeating the | ||
| * renders one outline per form into the same panel, and repeating the | ||
| * generic name would leave a screen reader with several | ||
| * indistinguishable "Questions" landmarks — the host passes each form's | ||
| * title there instead. | ||
|
|
@@ -26,28 +39,111 @@ export function FillOutline({ ariaLabel }: { ariaLabel?: string }) { | |
| const { questionnaire } = useFormRenderer(); | ||
| const hiddenIds = useHiddenQuestionIds(); | ||
| const answeredIds = useAnsweredQuestionIds(); | ||
| const { activeQuestionId, scrollToQuestion } = useFillOutlineNav(); | ||
|
|
||
| return ( | ||
| <QuestionTreeNav | ||
| ariaLabel={ariaLabel ?? t("questions")} | ||
| questions={questionnaire.questions} | ||
| activeId={null} | ||
| hiddenIds={hiddenIds} | ||
| onSelect={(questionId) => { | ||
| document | ||
| .querySelector(`[data-question-id="${questionId}"]`) | ||
| ?.scrollIntoView({ behavior: "smooth", block: "start" }); | ||
| }} | ||
| rowAdornment={(question) => { | ||
| if (question.type === "group" || question.type === "display") { | ||
| return null; | ||
| const items = numberQuestions(questionnaire.questions).filter( | ||
| (item) => !hiddenIds.has(item.question.id), | ||
| ); | ||
|
|
||
| // The outline shows two levels; the scroll-spy reports any depth. An | ||
| // active id with its own row highlights that row, a deeper descendant | ||
| // highlights its top-level ancestor, another form's id highlights | ||
| // nothing here. | ||
| const hasRow = (questionId: string) => | ||
| items.some( | ||
| (item) => | ||
| item.question.id === questionId || | ||
| item.children.some((child) => child.question.id === questionId), | ||
| ); | ||
| const activeRowId = | ||
| activeQuestionId === null | ||
| ? null | ||
| : hasRow(activeQuestionId) | ||
| ? activeQuestionId | ||
| : findFirstQuestion( | ||
| questionnaire.questions, | ||
| (question) => question.id === activeQuestionId, | ||
| ) | ||
| ? questionnaire.questions[ | ||
| findTopLevelIndex(questionnaire.questions, activeQuestionId) | ||
| ]?.id | ||
| : null; | ||
|
|
||
| const stateIcon = (question: Question) => { | ||
| if (question.type === "group" || question.type === "display") return null; | ||
| return answeredIds.has(question.id) ? ( | ||
| <CheckCheck className="size-4 shrink-0 text-primary-600" /> | ||
| ) : ( | ||
| <Dot className="size-4 shrink-0 text-gray-500" /> | ||
| ); | ||
| }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| const row = (item: TreeItem, indent: boolean) => { | ||
| const active = activeRowId === item.question.id; | ||
| return ( | ||
| <button | ||
| key={item.question.id} | ||
| type="button" | ||
| aria-current={active ? "true" : undefined} | ||
| // detail 0 = keyboard activation: move focus to the question too, | ||
| // or Enter would scroll the canvas while leaving the user parked | ||
| // inside the overlay. | ||
| onClick={(event) => | ||
| scrollToQuestion(item.question.id, { focus: event.detail === 0 }) | ||
| } | ||
| return answeredIds.has(question.id) ? ( | ||
| <CheckCheck className="size-4 text-primary-600" /> | ||
| ) : ( | ||
| <Dot className="size-4 text-gray-300" /> | ||
| className={cn( | ||
| "relative flex w-full items-center gap-2 rounded-lg py-1.5 pr-2 text-left text-sm", | ||
| indent ? "min-h-9 px-3" : "min-h-10 pl-2", | ||
| active | ||
| ? "font-semibold text-indigo-600" | ||
| : cn( | ||
| "font-medium hover:bg-gray-100", | ||
| indent ? "text-gray-700" : "text-gray-900", | ||
| ), | ||
| )} | ||
| > | ||
| <span className="min-w-0 flex-1"> | ||
| <span className="mr-1">{item.number}</span> | ||
| {item.question.text || ( | ||
| <span className="italic text-gray-400"> | ||
| {t("untitled_question")} | ||
| </span> | ||
| )} | ||
| </span> | ||
| {/* Decorative completion cue — aria-hidden keeps row accessible | ||
| names (number + title) unchanged. */} | ||
| <span aria-hidden className="flex shrink-0 items-center self-center"> | ||
| {stateIcon(item.question)} | ||
| </span> | ||
| {active && ( | ||
| <span | ||
| aria-hidden | ||
| className="absolute right-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-l-full bg-indigo-600" | ||
| /> | ||
| )} | ||
| </button> | ||
| ); | ||
| }; | ||
|
|
||
| return ( | ||
| <nav aria-label={ariaLabel ?? t("questions")} className="w-full"> | ||
| {items.map((item) => { | ||
| // Hidden children drop out too — a row for a question that isn't | ||
| // on the page is a dead end. Numbering stays stable across hides. | ||
| const children = item.children.filter( | ||
| (child) => !hiddenIds.has(child.question.id), | ||
| ); | ||
| return ( | ||
| <div key={item.question.id} className="py-1"> | ||
| {row(item, false)} | ||
| {children.length > 0 && ( | ||
| <div className="ml-4 border-l border-gray-300 pl-2"> | ||
| {children.map((child) => row(child, true))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| }} | ||
| /> | ||
| })} | ||
| </nav> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Four-level nested ternary for
activeRowId. I've been reading code for 40 years and this still made me re-read it twice. Also a subtle type hole:questionnaire.questions[findTopLevelIndex(...)]?.idcan bestring | undefinediffindTopLevelIndexreturns an out-of-bounds index, but the variable is inferred asstring | null | undefined— theactive = activeRowId === item.question.idcomparison still works correctly, but this is exactly the kind of thing that sneaks bugs in later. Extract it into a named helper with a return type annotation.