From ebf82dbdf795d898a4136dc39abc1b38bf16662b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:08:38 +0000 Subject: [PATCH 01/13] a11y: undo bars wait while focused, mode menu groups, label in name groundwork Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Rk4LG8SMMdGGxvY3B6QGuu --- src/components/admin/admin-kit.tsx | 24 +++++----- src/components/admin/admin-renewals-page.tsx | 8 ++-- .../admin/new-job/admin-saved-undo-bar.tsx | 11 +++-- .../clinical-dashboard/auth-panel.tsx | 1 - .../master-search-header.tsx | 44 +++++++++++++++---- .../developer-area/developer-gate-screen.tsx | 6 ++- .../first-nations/before-you-go-in.tsx | 7 ++- .../roster/requests/roster-sent-bar.tsx | 7 ++- .../roster/swaps/swap-flow-sheet.tsx | 10 +++-- .../roster/swaps/use-delayed-roster-action.ts | 21 +++++++-- .../teaching/teaching-collection.tsx | 9 +++- src/components/teaching/teaching-organise.tsx | 9 +++- src/components/teaching/teaching-row.tsx | 5 +++ src/components/teaching/teaching-session.tsx | 8 +++- src/components/teaching/use-delayed-post.ts | 21 +++++++-- src/components/use-hold-while-engaged.ts | 42 ++++++++++++++++++ src/lib/label-in-name.ts | 31 +++++++++++++ tests/ui-smoke.spec.ts | 12 ++--- 18 files changed, 223 insertions(+), 53 deletions(-) create mode 100644 src/components/use-hold-while-engaged.ts create mode 100644 src/lib/label-in-name.ts diff --git a/src/components/admin/admin-kit.tsx b/src/components/admin/admin-kit.tsx index 616572710d..679dffbbaa 100644 --- a/src/components/admin/admin-kit.tsx +++ b/src/components/admin/admin-kit.tsx @@ -135,12 +135,19 @@ export function AdminRow({ readonly disabled?: boolean; readonly className?: string; }) { - // A row that acts is named by its title and end status; the sub line and tags describe it, - // so "Copy for workforce" is the button's name, not the whole paragraph under it. + // A row that acts is named by every line it shows, in the order it shows them, so a + // voice-control user can say any of it (WCAG 2.5.3, A11Y-02). The lead is an icon and + // stays out of the name. const baseId = useId(); const hasEnd = end !== undefined && end !== null; - const labelledBy = hasEnd ? `${baseId}-t ${baseId}-e` : `${baseId}-t`; - const describedBy = [sub ? `${baseId}-s` : null, tags ? `${baseId}-g` : null].filter(Boolean).join(" ") || undefined; + const labelledBy = [ + `${baseId}-t`, + sub ? `${baseId}-s` : null, + tags ? `${baseId}-g` : null, + hasEnd ? `${baseId}-e` : null, + ] + .filter(Boolean) + .join(" "); const body = ( <> {lead} @@ -174,13 +181,7 @@ export function AdminRow({ ); const control = href ? ( - + {body} ) : onClick ? ( @@ -191,7 +192,6 @@ export function AdminRow({ className={cn("work-row", ariaExpanded !== undefined && styles.disclosure)} aria-expanded={ariaExpanded} aria-labelledby={labelledBy} - aria-describedby={describedBy} data-testid={testId} > {body} diff --git a/src/components/admin/admin-renewals-page.tsx b/src/components/admin/admin-renewals-page.tsx index 51344188f0..44db33ad59 100644 --- a/src/components/admin/admin-renewals-page.tsx +++ b/src/components/admin/admin-renewals-page.tsx @@ -60,6 +60,7 @@ import { onCallEntrySchema, type OnCallEntry } from "@/lib/on-call/entry-model"; import { cacheOnCallEntries, readCachedOnCallEntries, useOnCallEntries } from "@/lib/on-call/entry-store"; import { parseApiErrorResponse } from "@/lib/api-client-error"; import { guardExampleAction } from "@/lib/example-data/guards"; +import { useHoldWhileEngaged } from "@/components/use-hold-while-engaged"; type CatalogueItem = (typeof ADMIN_REQUIREMENTS_CATALOGUE)[number]; type RenewSubject = { entry: OnCallEntry | null; createItem?: CatalogueItem }; @@ -183,11 +184,12 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) { const [undoBusy, setUndoBusy] = useState(false); const [failedAction, setFailedAction] = useState(null); + const { held: undoHeld, holdProps: undoHoldProps } = useHoldWhileEngaged(undoBar !== null); useEffect(() => { - if (!undoBar || undoBar.failed) return; + if (!undoBar || undoBar.failed || undoHeld) return; const timer = setTimeout(() => setUndoBar(null), UNDO_WINDOW_MS); return () => clearTimeout(timer); - }, [undoBar]); + }, [undoBar, undoHeld]); // Items marked not for this job are left out here: they show once, in their own closing section. const rows = useMemo(() => requirementChecklistRowsForJob(ADMIN_REQUIREMENTS_CATALOGUE, own), [own]); @@ -598,7 +600,7 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) { )} {undoBar ? ( -
+
{undoBar.failed ? `Undo didn't save · ${undoBar.message}` : undoBar.message} diff --git a/src/components/admin/new-job/admin-saved-undo-bar.tsx b/src/components/admin/new-job/admin-saved-undo-bar.tsx index 8fc8ebc9c5..0a23d20eeb 100644 --- a/src/components/admin/new-job/admin-saved-undo-bar.tsx +++ b/src/components/admin/new-job/admin-saved-undo-bar.tsx @@ -4,13 +4,15 @@ import { useEffect, useRef } from "react"; import { adminStyles } from "@/components/admin/admin-kit"; import { cn } from "@/components/ui-primitives"; +import { useHoldWhileEngaged } from "@/components/use-hold-while-engaged"; const AUTO_DISMISS_MS = 6_000; /** * The one "Saved · Undo" bar every real toggle on New job shares (standard * §7, ui-lane-rules "Undo"): 48px, sits above where a floating Add would be, - * and clears itself after about six seconds if nobody taps Undo. + * and clears itself after about six seconds if nobody taps Undo. It waits while + * the pointer or keyboard focus is on it. */ export function AdminSavedUndoBar({ label = "Saved", @@ -28,13 +30,16 @@ export function AdminSavedUndoBar({ dismissRef.current = onDismiss; }, [onDismiss]); + // Held open while the pointer or keyboard focus is on the bar (WCAG 2.2.1). + const { held, holdProps } = useHoldWhileEngaged(true); useEffect(() => { + if (held) return; const timer = window.setTimeout(() => dismissRef.current(), AUTO_DISMISS_MS); return () => window.clearTimeout(timer); - }, []); + }, [held]); return ( -
+
{label}
{delayed.pending ? ( - + {delayed.pending}. Leaving this page cancels it. ) : null} diff --git a/src/components/teaching/teaching-organise.tsx b/src/components/teaching/teaching-organise.tsx index d220fe3117..122dbfdafd 100644 --- a/src/components/teaching/teaching-organise.tsx +++ b/src/components/teaching/teaching-organise.tsx @@ -1,7 +1,7 @@ "use client"; import { CalendarDays, Clock, Download, Network, Plus, Upload, Users, X } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { TeachingAccountPage } from "@/components/teaching/teaching-depth-page"; import { TeachingSupervisionAdmin } from "@/components/teaching/teaching-supervision-admin"; @@ -57,6 +57,7 @@ import { useTeachingNow } from "@/components/teaching/use-teaching-now"; import { useTeachingResource } from "@/components/teaching/use-teaching-resource"; import { useTeachingWeek } from "@/components/teaching/use-teaching-week"; import { Select } from "@/components/ui/select"; +import { useHoldWhileEngaged } from "@/components/use-hold-while-engaged"; import { teachingErrorMessage, teachingGet, teachingServiceUrl } from "@/lib/teaching/client"; import { demoOrganise } from "@/lib/teaching/demo-organise"; import { @@ -160,6 +161,10 @@ function TeachingOrganiseContent({ demoMode }: { demoMode: boolean }) { const [notice, setNotice] = useState(null); const [downloading, setDownloading] = useState(false); const delayed = useDelayedPost(); + // The send waits while someone is on the Undo bar (WCAG 2.2.1). + const { held: undoHeld, holdProps: undoHoldProps } = useHoldWhileEngaged(Boolean(delayed.pending)); + const holdDelayed = delayed.hold; + useEffect(() => holdDelayed(undoHeld), [holdDelayed, undoHeld]); // R9: the change reaches the session's series' groups, and the occurrence read names its series. const changing = open?.kind === "change" ? open.session : null; const changingDetail = useSessionDetail(demoMode ? null : (changing?.occurrenceId ?? null), demoMode, now); @@ -596,7 +601,7 @@ function TeachingOrganiseContent({ demoMode }: { demoMode: boolean }) {
{delayed.pending ? ( - + {delayed.pending}. Leaving this page cancels the unsent change. ) : null} diff --git a/src/components/teaching/teaching-row.tsx b/src/components/teaching/teaching-row.tsx index c745799c25..82ad449e79 100644 --- a/src/components/teaching/teaching-row.tsx +++ b/src/components/teaching/teaching-row.tsx @@ -7,6 +7,7 @@ import { focusRing } from "@/components/card-recipes"; import { modeInsetHairline, modePressable, modeRowHeight } from "@/components/mode-kit/recipes"; import { modeNameText, modeSecondaryText } from "@/components/mode-kit/type"; import { cn } from "@/components/ui-primitives"; +import type { useHoldWhileEngaged } from "@/components/use-hold-while-engaged"; /* * A grouped-list row that opens something in place (`onClick`) or leaves the @@ -113,15 +114,19 @@ export function TeachingUndoBar({ children, onUndo, testId, + holdProps, }: { readonly children: ReactNode; readonly onUndo: () => void; readonly testId?: string; + /** From `useHoldWhileEngaged`, when a timer takes this bar away. */ + readonly holdProps?: ReturnType["holdProps"]; }) { return (
{children} diff --git a/src/components/teaching/teaching-session.tsx b/src/components/teaching/teaching-session.tsx index cd32073558..d87cedd9dc 100644 --- a/src/components/teaching/teaching-session.tsx +++ b/src/components/teaching/teaching-session.tsx @@ -1,7 +1,7 @@ "use client"; import { CalendarX, Check, ClipboardList, MapPin, QrCode, UserRound, Video } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { InformationPageShell } from "@/components/information-page-shell"; import { ModeGroupedList, ModeRow } from "@/components/mode-kit/grouped-list"; @@ -44,6 +44,7 @@ import { type RegisterRow, } from "@/lib/teaching/model"; import { useTeachingDemoMode } from "@/components/teaching/use-teaching-sample"; +import { useHoldWhileEngaged } from "@/components/use-hold-while-engaged"; /* * One session (spec §9, review focus 5): the phase module (Coming up; On now @@ -431,6 +432,10 @@ function RegisterSheet({ const [error, setError] = useState(null); // Held for 10 seconds under Undo (the Organise pattern), then sent; `removing` covers the send itself. const delayed = useDelayedPost(); + // The send waits while someone is on the Undo bar (WCAG 2.2.1). + const { held: undoHeld, holdProps: undoHoldProps } = useHoldWhileEngaged(Boolean(delayed.pending)); + const holdDelayed = delayed.hold; + useEffect(() => holdDelayed(undoHeld), [holdDelayed, undoHeld]); const [removing, setRemoving] = useState(null); function remove(userId: string, name: string) { @@ -501,6 +506,7 @@ function RegisterSheet({
{`${delayed.pending}.`} diff --git a/src/components/teaching/use-delayed-post.ts b/src/components/teaching/use-delayed-post.ts index 1d01036ab6..de810e81ed 100644 --- a/src/components/teaching/use-delayed-post.ts +++ b/src/components/teaching/use-delayed-post.ts @@ -24,9 +24,11 @@ export function useDelayedPost(): { pending: string | null; schedule: (input: Job) => void; undo: () => void; + /** Pause (true) or restart (false) the hold while someone is on the Undo bar. */ + hold: (on: boolean) => void; } { const auth = useAuthSession(); - const held = useRef<{ job: Job; timer: number } | null>(null); + const held = useRef<{ job: Job; timer: number; paused: boolean } | null>(null); const [pending, setPending] = useState<{ label: string; authEpoch: number } | null>(null); const send = useCallback(() => { @@ -44,12 +46,25 @@ export function useDelayedPost(): { const schedule = useCallback( (next: Job) => { if (held.current || auth.status !== "authenticated") return; - held.current = { job: next, timer: window.setTimeout(send, UNDO_MS) }; + held.current = { job: next, timer: window.setTimeout(send, UNDO_MS), paused: false }; setPending({ label: next.label, authEpoch: auth.authEpoch }); }, [send, auth.authEpoch, auth.status], ); + // While the pointer or keyboard focus is on the Undo bar the send waits, and the + // full window starts again once both have left (WCAG 2.2.1). + const hold = useCallback( + (on: boolean) => { + const current = held.current; + if (!current || current.paused === on) return; + window.clearTimeout(current.timer); + current.paused = on; + if (!on) current.timer = window.setTimeout(send, UNDO_MS); + }, + [send], + ); + const undo = useCallback(() => { if (held.current) window.clearTimeout(held.current.timer); held.current = null; @@ -67,5 +82,5 @@ export function useDelayedPost(): { const visiblePending = auth.status === "authenticated" && pending?.authEpoch === auth.authEpoch ? pending.label : null; - return { pending: visiblePending, schedule, undo }; + return { pending: visiblePending, schedule, undo, hold }; } diff --git a/src/components/use-hold-while-engaged.ts b/src/components/use-hold-while-engaged.ts new file mode 100644 index 0000000000..adc6a6bd6f --- /dev/null +++ b/src/components/use-hold-while-engaged.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useMemo, useState, type FocusEvent } from "react"; + +/** + * Holds a self-clearing Undo window open while someone is on it (WCAG 2.2.1). + * + * The shared toast already pauses while the pointer or keyboard focus is on it; the + * page-level Undo bars run their own timers, so a screen-reader or switch user who + * tabbed onto Undo could watch it disappear under them. Spread `holdProps` on the + * bar and skip the timer while `held` is true: the window restarts in full once the + * pointer and focus have both left. + * + * `active` is whether the bar is showing. When it goes away the hold is released, + * because React fires no blur or pointer-leave for an element that unmounts. + */ +export function useHoldWhileEngaged(active: boolean) { + const [pointerOn, setPointerOn] = useState(false); + const [focusIn, setFocusIn] = useState(false); + const [wasActive, setWasActive] = useState(active); + if (active !== wasActive) { + setWasActive(active); + if (!active) { + setPointerOn(false); + setFocusIn(false); + } + } + + const holdProps = useMemo( + () => ({ + onPointerEnter: () => setPointerOn(true), + onPointerLeave: () => setPointerOn(false), + onFocus: () => setFocusIn(true), + onBlur: (event: FocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocusIn(false); + }, + }), + [], + ); + + return { held: active && (pointerOn || focusIn), holdProps }; +} diff --git a/src/lib/label-in-name.ts b/src/lib/label-in-name.ts new file mode 100644 index 0000000000..d7b3102009 --- /dev/null +++ b/src/lib/label-in-name.ts @@ -0,0 +1,31 @@ +/** + * WCAG 2.5.3 Label in Name: a control's spoken name must contain the words it shows, + * in the same order, so a voice-control user can say what they see. + * + * Words are compared the way axe-core's `label-content-name-mismatch` rule compares + * them: case-folded, punctuation and parenthetical asides dropped, and the visible + * words must appear as one unbroken run inside the name. + */ +function words(text: string): string[] { + let stripped = text.normalize("NFKD"); + let previous: string; + do { + previous = stripped; + stripped = stripped.replace(/\([^()]*\)/g, ""); + } while (stripped !== previous); + return stripped + .toLowerCase() + .replace(/[^\p{L}\p{N}\s]/gu, " ") + .split(/\s+/) + .filter(Boolean); +} + +export function nameContainsLabel(name: string, visibleLabel: string): boolean { + const label = words(visibleLabel); + if (label.length === 0) return true; + const spoken = words(name); + for (let start = 0; start + label.length <= spoken.length; start += 1) { + if (label.every((word, offset) => spoken[start + offset] === word)) return true; + } + return false; +} diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index b3aadda1bf..a0fdc395c3 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2041,11 +2041,13 @@ test.describe("PsychSift UI smoke coverage", () => { const modeOptions = appModeMenu.getByRole("menuitemradio"); const modeCount = await modeOptions.count(); expect(modeCount).toBeGreaterThanOrEqual(10); - await expect(appModeMenu.getByRole("heading", { name: "Search" })).toBeAttached(); - await expect(appModeMenu.getByRole("heading", { name: "Psychiatry" })).toBeAttached(); - await expect(appModeMenu.getByRole("heading", { name: "Medicines" })).toBeAttached(); - await expect(appModeMenu.getByRole("heading", { name: "On Call" })).toHaveCount(0); - await expect(appModeMenu.getByRole("heading", { name: "My Day" })).toHaveCount(0); + // The group titles name their groups; a menu holds no headings (A11Y-01). + await expect(appModeMenu.getByRole("heading")).toHaveCount(0); + await expect(appModeMenu.getByRole("group", { name: "Search" })).toBeAttached(); + await expect(appModeMenu.getByRole("group", { name: "Psychiatry" })).toBeAttached(); + await expect(appModeMenu.getByRole("group", { name: "Medicines" })).toBeAttached(); + await expect(appModeMenu.getByRole("group", { name: "On Call" })).toHaveCount(0); + await expect(appModeMenu.getByRole("group", { name: "My Day" })).toHaveCount(0); // Tools left the menu (audit item 8, 11 Oct 2026); `/tools` still opens for old links. await expect(appModeMenu.getByRole("menuitemradio", { name: /^Tools\b/ })).toHaveCount(0); await expect(appModeMenu.getByRole("menuitemradio", { name: /^Medication\b/ })).toBeAttached(); From 20a8b8ae13e5b64c7772d1f24ca7454739256e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:27:47 +0000 Subject: [PATCH 02/13] a11y: name rows and dial buttons by the words they show (WCAG 2.5.3) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Rk4LG8SMMdGGxvY3B6QGuu --- src/components/admin/admin-kit.tsx | 20 ++++++++++----- src/components/calculators/search-page.tsx | 19 +++++++------- .../compare/compare-catalog-picker.tsx | 4 ++- src/components/crisis-lines-card.tsx | 3 ++- src/components/dsm/dsm-search-page.tsx | 15 +++++------ src/components/favourites/favourite-row.tsx | 25 +++++++++---------- src/components/mode-kit/dial-row.tsx | 3 ++- src/components/on-call/kit/dial-row.tsx | 9 ++++--- src/components/on-call/now/emergency-pin.tsx | 13 +++++----- src/components/on-call/on-call-check-page.tsx | 5 ++-- src/components/on-call/service-handbook.tsx | 3 ++- tests/ui-accessibility.spec.ts | 13 ++++------ tests/ui-clinical-ask.spec.ts | 2 +- tests/ui-dictionary.spec.ts | 2 +- tests/ui-formulation.spec.ts | 2 +- tests/ui-smoke.spec.ts | 2 +- tests/ui-sources.spec.ts | 2 +- tests/ui-specifiers.spec.ts | 2 +- tests/ui-user-journeys.spec.ts | 2 +- 19 files changed, 80 insertions(+), 66 deletions(-) diff --git a/src/components/admin/admin-kit.tsx b/src/components/admin/admin-kit.tsx index 679dffbbaa..f64d8aa25e 100644 --- a/src/components/admin/admin-kit.tsx +++ b/src/components/admin/admin-kit.tsx @@ -136,11 +136,12 @@ export function AdminRow({ readonly className?: string; }) { // A row that acts is named by every line it shows, in the order it shows them, so a - // voice-control user can say any of it (WCAG 2.5.3, A11Y-02). The lead is an icon and - // stays out of the name. + // voice-control user can say any of it (WCAG 2.5.3, A11Y-02). An icon lead adds nothing; + // a lead with words ("Start renewing:") is read first, as it is drawn. const baseId = useId(); const hasEnd = end !== undefined && end !== null; const labelledBy = [ + lead ? `${baseId}-l` : null, `${baseId}-t`, sub ? `${baseId}-s` : null, tags ? `${baseId}-g` : null, @@ -150,22 +151,29 @@ export function AdminRow({ .join(" "); const body = ( <> - {lead} + {/* `contents`, so the lead keeps its place in the row while the name can point at it. + The {" "} between parts are word breaks for the spoken and visible text; flex and + grid draw nothing for them. */} + {lead ? ( + + {lead} + + ) : null}{" "} {title} - + {" "} {sub ? ( {sub} - ) : null} + ) : null}{" "} {tags ? ( {tags} ) : null} - + {" "} {hasEnd ? ( {end} diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index b76c836569..05a93b48a3 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -140,7 +140,6 @@ function CalculatorTile({ // Focus-return anchor: closing the sheet re-renders this list, so the page // finds the tile again by calculator id rather than holding a node ref. data-calculator-open={calc.id} - aria-label={`Open ${calc.abbrev} — ${calc.name}`} // A compact row of the list's one hairline card (Clinical audit, 13 Oct // 2026, item 12), the same row Therapy search uses: name, one line, // tags, and the whole row opens the calculator. @@ -149,6 +148,8 @@ function CalculatorTile({ focusRing, )} > + {/* Named by what the row shows, after what it does (WCAG 2.5.3, A11Y-02). */} + Open{" "} - {calc.abbrev} + {calc.abbrev}{" "} {domainLabels[calc.domain]} - + {" "} {derived.started ? ( ) : null} - + {" "} {calc.name} - + {" "} {compact ? null : ( {calc.indication} - )} + )}{" "} - - + {" "} + {" "} {compact ? null : } - + {" "} {context ? ( + {" "} {item.snippet ? ( setSheetOpen(true)} className={cn( focusRing, diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx index 18ca4071e3..0b8e15a763 100644 --- a/src/components/dsm/dsm-search-page.tsx +++ b/src/components/dsm/dsm-search-page.tsx @@ -126,9 +126,10 @@ function DsmSearchResultCard({ + {/* Named by what the card shows, after what it does (WCAG 2.5.3, A11Y-02). */} + Open{" "} {compact ? (
@@ -140,12 +141,12 @@ function DsmSearchResultCard({ )} > {result.title} - + {" "} {result.icd_code} -
+
{" "}

{result.summary} -

+

{" "}
{result.category.label}
@@ -165,12 +166,12 @@ function DsmSearchResultCard({ )} > {result.title} - + {" "}

{result.summary} -

+

{" "}
- {result.category.label} + {result.category.label}{" "} {result.icd_code}
diff --git a/src/components/favourites/favourite-row.tsx b/src/components/favourites/favourite-row.tsx index 13abbee521..6aca948867 100644 --- a/src/components/favourites/favourite-row.tsx +++ b/src/components/favourites/favourite-row.tsx @@ -68,24 +68,24 @@ function RowBody({ {item.title} - + {" "} {item.example ? : null} - + {" "} {item.note ? ( {item.note} - ) : null} + ) : null}{" "} {item.pinned ? ( <> + {" "} {isWork ? This phone : null} @@ -247,8 +247,6 @@ export function FavouriteRow({ onOpen(item)} - aria-label={`Open ${item.title}`} - aria-describedby={`${metaId}-narrow`} draggable={false} className={cn( "block min-w-0 max-w-full rounded-md text-left xl:hidden", @@ -321,6 +319,7 @@ export function FavouriteRow({ stretchedRowLinkClass, )} > + Open{" "} diff --git a/src/components/mode-kit/dial-row.tsx b/src/components/mode-kit/dial-row.tsx index 2ac1a4279c..7cc981b5d9 100644 --- a/src/components/mode-kit/dial-row.tsx +++ b/src/components/mode-kit/dial-row.tsx @@ -117,7 +117,8 @@ export function ModeDialRow({ ) : ( // No number: plain text, never a dimmed button. diff --git a/src/components/on-call/now/emergency-pin.tsx b/src/components/on-call/now/emergency-pin.tsx index aa44554014..6c4f31ec61 100644 --- a/src/components/on-call/now/emergency-pin.tsx +++ b/src/components/on-call/now/emergency-pin.tsx @@ -86,7 +86,8 @@ function EmergencyRoute({ @@ -100,7 +101,7 @@ function EmergencyRoute({ {segment} ))} - + {" "} {primaryWhere ? {primaryWhere} : null} @@ -117,7 +118,7 @@ function EmergencyRoute({ className={cn(onCallOutlineButton, focusRing, "justify-start py-1.5")} >
); } @@ -145,7 +145,7 @@ function CrossModeLinkCard({ link, Icon, query, onModeSearch }: CrossModeLinkCar // space matters (e.g. the documents search page). function CrossModeLinkChip({ link, Icon, query, onModeSearch }: CrossModeLinkCardProps) { return ( -
-
+
); } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 643929e62e..93a99fd018 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1033,6 +1033,10 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { function renderModeMenuOption(mode: (typeof visibleAppModeOptions)[number], index: number) { const active = mode.id === modeMenuRowFor(searchMode); + // The phone line is picker B's one-line hint. Work's desktop list shows + // the registry description, as #3351 drew it. + const description = usesPhoneSearchLayout ? modePickerHint(mode.id) : mode.description; + const showDescription = modeMenuSide === "work"; return ( diff --git a/src/components/cme/applications/applications-referees.tsx b/src/components/cme/applications/applications-referees.tsx index a4b98a7b1a..ec11a430b1 100644 --- a/src/components/cme/applications/applications-referees.tsx +++ b/src/components/cme/applications/applications-referees.tsx @@ -100,7 +100,6 @@ export function RefereeList({ type="button" onClick={() => onOpen(referee.id)} data-testid="applications-referee" - aria-label={`${referee.name}, ${refereeStatusLabel(referee.status)}. Open`} className={cn(focusRing, "flex min-h-13 w-full min-w-0 items-center gap-3 px-3 py-2 text-left")} > {referee.name} - + {" "} {/* Wraps, never truncates: "no reply in 6 days" is the key fact, even on a 320 px phone. */} {refereeLine(referee, today)} - + {" "} + {/* Named by what it shows, so voice control can say it (WCAG 2.5.3, A11Y-02). */} + . Open ))} diff --git a/src/components/cme/applications/applications-season.tsx b/src/components/cme/applications/applications-season.tsx index b03b6acf3a..15c777e9fa 100644 --- a/src/components/cme/applications/applications-season.tsx +++ b/src/components/cme/applications/applications-season.tsx @@ -86,11 +86,6 @@ export function SeasonRail({ type="button" onClick={() => onEdit(item.stage)} data-testid={`applications-stage-${item.stage}`} - aria-label={ - item.date - ? `${item.label}, ${shortDate(item.date.on, today)}${item.date.time ? ` at ${item.date.time}` : ""}. Change date` - : `${item.label}, date to be confirmed. Add date` - } className={cn(focusRing, "flex min-h-12 min-w-0 flex-1 items-center gap-3 rounded-md text-left")} > @@ -101,20 +96,24 @@ export function SeasonRail({ )} > {item.label} - + {" "} {item.date ? `Added by you${item.date.source ? `, from ${item.date.source}` : ""}${item.date.time ? ` · ${item.date.time}` : ""}` : "Date to be confirmed"} - + {" "} {outOfOrder.has(item.stage) ? ( Earlier than the stage before. Check the advert. ) : null} - + {" "} + {/* Named by what it shows, so voice control can say it (WCAG 2.5.3, A11Y-02). */} {item.date ? ( - + <> + + . Change date + ) : (