fix(meds): focus management and full-row hit area on the wizard

Step advance moves focus into the new step's first input so
keyboard and screen-reader users land where they need to type. The
cadence-picker rows now register clicks across the full 44 px row,
not just the radio glyph + label. The "describe it" trigger on
step 1 rises to 44 px so it clears the WCAG 2.5.5 floor the rest of
the codebase honours.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 10:23:28 +02:00
1 parent 4ccb70aff8
commit a730ebf07b
2 files changed
+39 -13

No files matched your search

@@ -308,7 +308,13 @@ function CadenceOption({
data-slot="cadence-option"
data-kind={kind}
>
<div className="flex min-h-11 items-center gap-3">
{/* Wrapping the row in <label> makes the entire 44 px-tall card
a click target for the radio (WCAG 2.5.5) instead of just the
16 px radio glyph + the label text node. */}
<label
htmlFor={inputId}
className="flex min-h-11 cursor-pointer items-center gap-3 select-none"
>
<input
id={inputId}
type="radio"
@@ -316,17 +322,11 @@ function CadenceOption({
checked={selected}
disabled={disabled}
onChange={onPick}
aria-label={label}
className="text-primary focus-visible:ring-ring h-4 w-4 cursor-pointer focus-visible:outline-none focus-visible:ring-2"
data-slot="cadence-option-radio"
/>
<Label
htmlFor={inputId}
className="cursor-pointer select-none text-sm font-medium"
>
{label}
</Label>
</div>
<span className="text-sm font-medium">{label}</span>
</label>
{selected && kind === "weekdays" && (
<div className="mt-3">
@@ -72,7 +72,7 @@
* .errors.fieldRequired — "Required"
*/
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Sparkles } from "lucide-react";
@@ -522,10 +522,37 @@ export function CreationWizard({
const [submitError, setSubmitError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
// Ref on the step body so we can pull focus into the first
// interactive control whenever the wizard advances. Without this a
// keyboard / screen-reader user stays parked on the Back / Next
// button across step transitions instead of landing on the new
// step's input.
const bodyRef = useRef<HTMLDivElement | null>(null);
const isFirstRenderRef = useRef(true);
const applyPartial = useCallback((partial: Partial<WizardPayload>) => {
setPayload((prev) => ({ ...prev, ...partial }));
}, []);
// Focus the first interactive element when the step changes.
// Skip the initial render so opening the wizard never steals focus
// from the page chrome.
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
return;
}
const node = bodyRef.current;
if (!node) return;
const target = node.querySelector<HTMLElement>(
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (!target) return;
// preventScroll keeps the focus jump silent for reduced-motion
// users; the surrounding layout already keeps the body in view.
target.focus({ preventScroll: true });
}, [step]);
const canContinue = useMemo(
() => validateStep(payload, step),
[payload, step],
@@ -627,7 +654,7 @@ export function CreationWizard({
<p className="text-muted-foreground text-xs">{stepOf}</p>
<CardTitle className="text-base">{stepTitle}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-4" ref={bodyRef}>
{step === 1 && (
<Step1NameDose
payload={payload}
@@ -733,9 +760,8 @@ function Step1NameDose({
<Button
type="button"
variant="outline"
size="sm"
onClick={onNlClick}
className="w-full"
className="h-11 w-full"
data-slot="wizard-nl-button"
>
<Sparkles className="mr-2 h-4 w-4" aria-hidden="true" />