diff --git a/e2e/mental-health-check-in.spec.ts b/e2e/mental-health-check-in.spec.ts index c7867b16b..85e4b77bb 100644 --- a/e2e/mental-health-check-in.spec.ts +++ b/e2e/mental-health-check-in.spec.ts @@ -262,8 +262,14 @@ test.describe("mental-health check-in, in the reader's language", () => { await expect( wizard.locator('[data-slot="check-in-question"]'), ).toHaveAttribute("lang", locale); - if (expected === 1) + if (expected === 1) { + // On screen together with the question, at every width: the step + // scrolls to its top block, note and stem included. + await expect( + wizard.locator('[data-slot="check-in-unvalidated-note"]'), + ).toBeInViewport(); await expectNoSeriousViolations(page, `${locale} ${c.id}`); + } await wizard.locator('[data-slot="check-in-back-to-landing"]').click(); } @@ -295,6 +301,19 @@ test.describe("mental-health check-in, in the reader's language", () => { ); const question = wizard.locator('[data-slot="check-in-question"]'); const translated = await question.textContent(); + // The note and its switch travel with every step, not only the first. + for (const step of [1, 2]) { + const current = wizard.locator( + `[data-slot="check-in-step"][data-step="${step}"]`, + ); + await expect( + current.locator('[data-slot="check-in-unvalidated-note"]'), + ).toBeInViewport(); + await current.locator('[role="radio"]').first().click(); + } + await wizard.locator('[data-slot="check-in-back"]').click(); + await wizard.locator('[data-slot="check-in-back"]').click(); + await expect(question).toHaveText(translated ?? ""); const toggle = wizard.locator('[data-slot="check-in-wording-toggle"]'); await toggle.click(); await expect(toggle).toHaveAttribute("data-showing", "original_en"); diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 618e52e47..f6a196e4b 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 411, - "total": 148064, + "count": 410, + "total": 147860, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -210,7 +210,6 @@ "components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516, "components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222, "components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230, - "components/mental-health/check-in-wizard.tsx :: CheckInWizard": 204, "components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 202, "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256, "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270, diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx index 0eb87c3d6..75a832828 100644 --- a/src/components/mental-health/check-in-wizard.tsx +++ b/src/components/mental-health/check-in-wizard.tsx @@ -14,7 +14,8 @@ * reader's language (one chunk per language, `useQuestionnaireWording`): the * published validated translation where one exists, otherwise a translation * written for HealthLog under a note that the result is orientation, with a - * switch to the English original. The header carries the app's own title and + * switch to the English original. The note sits at the top of every step's + * card, above the stem, so it stays on screen with the question. The header carries the app's own title and * one explanation line; the disclaimer stays on the landing. * * Keyboard: Tab reaches the selected answer (or the first), the arrow keys @@ -27,6 +28,7 @@ import { useRef, useState, type KeyboardEvent, + type ReactNode, type RefObject, } from "react"; import { ArrowLeft } from "lucide-react"; @@ -82,8 +84,7 @@ export function CheckInWizard({ }) { const { t } = useTranslations(); const def = INSTRUMENTS[instrument]; - const { wording, validated, englishOriginal, toggle } = - useCheckInWording(instrument); + const { wording, englishOriginal, note } = useCheckInWording(instrument); const steps = checkInSteps(def.itemCount, hasFunctionalItem(instrument)); const [step, setStep] = useState(1); @@ -161,12 +162,6 @@ export function CheckInWizard({ title={t(`mentalHealth.instrument.${def.i18nKey}`)} description={t(`mentalHealth.instrumentDescription.${def.i18nKey}`)} /> - {!validated && ( - - )} @@ -195,6 +190,7 @@ export function CheckInWizard({ functional={functional} functionalStep={steps.functionalStep} wording={wording} + note={note} headingRef={headingRef} onEdit={edit} /> @@ -206,6 +202,7 @@ export function CheckInWizard({ onFunctional={onFunctional} selected={onFunctional ? functional : items[questionIndex]} wording={wording} + note={note} headingRef={headingRef} disabled={isPending} onChoose={choose} @@ -298,13 +295,33 @@ function useCheckInWording(instrument: InstrumentId) { const wording: StepWording | undefined = englishOriginal ? { text: english, lang: "en" } : own && { text: own, lang: locale }; - const toggle = () => setShowOriginal((v) => !v); - return { - wording, - validated: own?.validated ?? true, - englishOriginal, - toggle, - }; + // Every step carries the note and its switch, inside the step's card. + const note = + own?.validated === false ? ( + setShowOriginal((v) => !v)} + /> + ) : null; + return { wording, englishOriginal, note }; +} + +/** + * The top of a step: the note, the stem and the question. On arrival it is + * scrolled into view as a block, so the note and the stem stay on screen + * with the question; a block already in view does not move, and focusing + * the question afterwards finds it visible. + */ +function StepTop({ children }: { children: ReactNode }) { + const ref = useRef(null); + useEffect(() => { + ref.current?.scrollIntoView?.({ block: "nearest" }); + }, []); + return ( +
+ {children} +
+ ); } /** The space a step takes while its language's text loads. */ @@ -385,6 +402,7 @@ function QuestionStep({ onFunctional, selected, wording, + note, headingRef, disabled, onChoose, @@ -395,6 +413,8 @@ function QuestionStep({ selected: number; /** Undefined while the language's text is still loading. */ wording: StepWording | undefined; + /** The unvalidated-translation note, where it applies. */ + note: ReactNode; headingRef: RefObject; disabled: boolean; onChoose: (value: number) => void; @@ -423,21 +443,24 @@ function QuestionStep({ data-step={step} data-functional={onFunctional || undefined} > - {stem && ( -

- {stem} -

- )} -

- {question} -

+ + {note} + {stem && ( +

+ {stem} +

+ )} +

+ {question} +

+
{onFunctional && (

{t("mentalHealth.functionalOptionalHint")} @@ -551,6 +574,7 @@ function ReviewStep({ functional, functionalStep, wording, + note, headingRef, onEdit, }: { @@ -559,6 +583,7 @@ function ReviewStep({ functional: number; functionalStep: number | null; wording: StepWording | undefined; + note: ReactNode; headingRef: RefObject; onEdit: (step: number) => void; }) { @@ -590,13 +615,16 @@ function ReviewStep({ return (

-

- {t("mentalHealth.review.title")} -

+ + {note} +

+ {t("mentalHealth.review.title")} +

+

{t("mentalHealth.review.description")}

    {rows.map((row, i) => (