From e94b7f828c6171f6261bedccc510d541456e4986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 08:26:24 +0200 Subject: [PATCH] fix(mental-health): keep the unvalidated-translation note on screen with every step On a phone, arriving on a step scrolled to the question and left the note and the switch to the English original at the top of the page, out of view. They now sit at the top of every step's card, above the stem, and the step scrolls its top block (note, stem, question) into view as one, so a block already on screen does not move and focusing the question finds it visible. The review step carries the note the same way. The phone e2e run asserts the note is in the viewport on the first and a later step. --- e2e/mental-health-check-in.spec.ts | 21 +++- .../baselines/source-function-lines.json | 5 +- .../mental-health/check-in-wizard.tsx | 104 +++++++++++------- 3 files changed, 88 insertions(+), 42 deletions(-) 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) => (