From e2bbc1914ae281bbff5aeff83f7ede3a687be789 Mon Sep 17 00:00:00 2001 From: Queen-T16 Date: Sun, 18 Oct 2026 22:05:46 +0000 Subject: [PATCH 1/2] feat(network): poor-network resilience: offline detection, retry policy, and safe mutation retries --- ARCHITECTURE_ESSENTIALS.md | 43 +- ROADMAP.md | 10 + docs/accessibility/audit.md | 136 ++++ docs/accessibility/patterns.md | 157 +++++ package-lock.json | 642 +++++++++++++++++- package.json | 3 + src/App.tsx | 158 +++-- src/components/Button.module.css | 5 +- src/components/CommandPalette.a11y.test.tsx | 53 ++ src/components/CommandPalette.module.css | 6 +- src/components/CommandPalette.tsx | 133 ++-- src/components/Dialog.tsx | 58 ++ src/components/Layout.module.css | 24 +- src/components/Layout.tsx | 67 +- src/components/LiveRegion.tsx | 23 + src/components/OfflineBanner.module.css | 18 + src/components/OfflineBanner.test.tsx | 36 + src/components/OfflineBanner.tsx | 43 ++ src/components/ShortcutsHelp.module.css | 2 +- src/components/ShortcutsHelp.tsx | 80 +-- src/components/Spinner.tsx | 2 +- src/components/TextField.module.css | 15 +- src/components/TextField.tsx | 16 +- .../components/DataExportSection.module.css | 5 +- .../account/components/DataExportSection.tsx | 105 +-- .../DeleteAccountSection.module.css | 5 +- .../auth/components/LoginForm.a11y.test.tsx | 41 ++ src/features/auth/components/LoginForm.tsx | 20 +- .../auth/components/SignupForm.a11y.test.tsx | 33 + src/features/auth/components/SignupForm.tsx | 20 +- .../components/CareTimeline.module.css | 10 +- .../care-thread/components/CareTimeline.tsx | 18 +- .../components/ConsultationNote.tsx | 23 +- .../dependents/components/DependentsList.tsx | 14 +- .../components/HandoverNotice.module.css | 2 +- .../components/FacilityMemberList.tsx | 17 +- .../components/FacilityTeam.a11y.test.tsx | 61 ++ .../components/FacilityTeam.module.css | 43 +- .../components/FacilityTeamView.tsx | 7 +- .../FulfillmentQueueWithAttribution.tsx | 20 +- .../components/InviteMemberModal.tsx | 105 +-- .../components/PendingInvitesList.tsx | 20 +- .../components/RemoveMemberModal.tsx | 70 +- .../components/HealthSection.tsx | 8 +- .../insights/components/DateRangeSelector.tsx | 93 +-- .../insights/components/NoShowMetricsCard.tsx | 54 +- .../components/PracticeInsights.a11y.test.tsx | 58 ++ .../components/PracticeInsights.module.css | 21 +- .../components/PracticeInsightsView.tsx | 7 +- .../components/TurnaroundMetricsCard.tsx | 92 +-- .../insights/components/UtilisationChart.tsx | 29 +- .../components/DoctorInbox.module.css | 12 +- .../messaging/components/DoctorInbox.tsx | 24 +- .../components/UnreadBadge.module.css | 2 +- .../orders/components/FacilityCard.tsx | 9 +- .../components/FacilityFinder.module.css | 14 +- .../orders/components/FacilityMap.tsx | 1 + .../components/CreatePaymentIntentForm.tsx | 10 +- .../components/PaymentIntentPanel.tsx | 7 +- src/features/payments/hooks/useWalletLink.ts | 9 +- .../queue/components/QueuePanel.a11y.test.tsx | 79 +++ .../queue/components/QueuePanel.module.css | 15 +- src/features/queue/components/QueuePanel.tsx | 168 +++-- src/hooks/useAnnouncer.test.tsx | 52 ++ src/hooks/useAnnouncer.ts | 56 ++ src/hooks/useFocusTrap.ts | 87 +++ src/hooks/useOnlineStatus.ts | 16 + src/i18n/LocaleSwitcher.module.css | 23 + src/i18n/LocaleSwitcher.tsx | 31 + src/i18n/LocaleSync.test.tsx | 43 ++ src/i18n/LocaleSync.tsx | 21 + src/i18n/format.test.ts | 97 +++ src/i18n/format.ts | 291 ++++++++ src/i18n/locale-store.ts | 38 ++ src/i18n/useFormat.ts | 14 + src/main.tsx | 27 +- .../AccessibilityStatementPage.module.css | 27 + src/pages/AccessibilityStatementPage.test.tsx | 25 + src/pages/AccessibilityStatementPage.tsx | 105 +++ src/pages/AdminDashboardPage.a11y.test.tsx | 36 + src/pages/AdminDashboardPage.tsx | 30 +- src/pages/DoctorDashboardPage.tsx | 2 +- src/pages/FacilityDashboardPage.tsx | 6 +- src/pages/FacilityTeamPage.tsx | 6 +- src/pages/HomePage.module.css | 2 +- src/pages/HomePage.tsx | 18 +- src/services/api-client.test.ts | 127 ++++ src/services/api-client.ts | 198 ++++-- src/services/payments-service.ts | 29 +- src/styles/contrast.test.ts | 70 ++ src/styles/global.css | 61 +- src/test/axe.d.ts | 11 + src/test/setup.ts | 4 + tsconfig.app.json | 2 +- 94 files changed, 3973 insertions(+), 663 deletions(-) create mode 100644 docs/accessibility/audit.md create mode 100644 docs/accessibility/patterns.md create mode 100644 src/components/CommandPalette.a11y.test.tsx create mode 100644 src/components/Dialog.tsx create mode 100644 src/components/LiveRegion.tsx create mode 100644 src/components/OfflineBanner.module.css create mode 100644 src/components/OfflineBanner.test.tsx create mode 100644 src/components/OfflineBanner.tsx create mode 100644 src/features/auth/components/LoginForm.a11y.test.tsx create mode 100644 src/features/auth/components/SignupForm.a11y.test.tsx create mode 100644 src/features/facility-team/components/FacilityTeam.a11y.test.tsx create mode 100644 src/features/insights/components/PracticeInsights.a11y.test.tsx create mode 100644 src/features/queue/components/QueuePanel.a11y.test.tsx create mode 100644 src/hooks/useAnnouncer.test.tsx create mode 100644 src/hooks/useAnnouncer.ts create mode 100644 src/hooks/useFocusTrap.ts create mode 100644 src/hooks/useOnlineStatus.ts create mode 100644 src/i18n/LocaleSwitcher.module.css create mode 100644 src/i18n/LocaleSwitcher.tsx create mode 100644 src/i18n/LocaleSync.test.tsx create mode 100644 src/i18n/LocaleSync.tsx create mode 100644 src/i18n/format.test.ts create mode 100644 src/i18n/format.ts create mode 100644 src/i18n/locale-store.ts create mode 100644 src/i18n/useFormat.ts create mode 100644 src/pages/AccessibilityStatementPage.module.css create mode 100644 src/pages/AccessibilityStatementPage.test.tsx create mode 100644 src/pages/AccessibilityStatementPage.tsx create mode 100644 src/pages/AdminDashboardPage.a11y.test.tsx create mode 100644 src/services/api-client.test.ts create mode 100644 src/styles/contrast.test.ts create mode 100644 src/test/axe.d.ts diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 8532f87..1d0f1c1 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -20,7 +20,9 @@ those first if you're new to the org, not just this file. 2. Mirror `docforum-core`'s data model shapes in `src/types/models.ts` — don't invent parallel frontend-only shapes for the same entities. 3. Structure: `pages/ components/ features/ hooks/ services/ store/ types/` - — keep this shape. + — keep this shape. `src/i18n/` was added in Phase W7 for locale-aware + formatting and direction (issue #88); it is not a place for business + logic. ## Where things live - `src/services/` — API client calls to `docforum-core`. `api-client.ts` is @@ -172,6 +174,45 @@ Undo behaviour: start/complete are applied optimistically and can be reverted wi - `GET /facilities/:facilityId/fulfillment-queue` → `FulfillmentQueueItem[]` — queue items with action attribution (`attribution: { action, performedByUserId, performedByName, performedByRole, timestamp, notes }`). - `POST /facilities/:facilityId/fulfillment-queue/:itemId/action` (body: `{action: 'fulfilled' | 'rejected', notes?}`) → `FulfillmentQueueItem` — records the acting staff user's identity and role for PRD §7 audit trail. +## Known assumptions — Phase W7 (issue #85, #88, #89, #90) + +### Idempotency-Key for safe mutation retries (#85, docforum-core#48) +- Mutating requests that are safe to retry send `Idempotency-Key: `. + The header is set in `src/services/api-client.ts` from + `RequestOptions.idempotencyKey`; one key is generated per user action + and reused across that action's network retries. +- Assumed contract: docforum-core deduplicates a repeated `POST` with the + same key and returns the original result instead of applying it twice. + This repo cannot enforce that; until docforum-core#48 lands, retries + are still limited to two attempts and the endpoint should treat an + unknown key as a fresh request (never double-charge). +- `api-client.ts` never retries a mutation without a key, no matter what + React Query is configured to do (its mutation retry is disabled in + `main.tsx`). Reads retry up to 3 times on `408`, `429`, `5xx` and network + failures, with capped exponential backoff and jitter. +- The booking flow has no frontend service yet (W2 booking is not built). + When it lands, its mutation calls must use `createIdempotencyKey()` the + same way `payments-service.ts` does. + +### Locale-aware formatting and RTL (#88) +- No API change. Locale and time zone are client-only UI state + (`src/i18n/locale-store.ts`, in-memory like the other stores). The API + continues to return ISO-8601 timestamps and bigint-string amounts. +- `formatAssetAmount(amount, asset, {decimals})` assumes `amount` is an + integer minor-unit string (stroops for XLM, cents for USDC). If + docforum-core ever returns decimals instead, that helper is the single + place to change. +- A real i18n framework/translation catalogue is still out of scope; the + current locale switch covers formatting and direction only. + +### Accessibility conventions (#89, #90) +- No API change. All modals must use `src/components/Dialog.tsx`; tables + need captions/scoped headers; live updates use `useAnnouncer` batching. + See `docs/accessibility/patterns.md` and the audit in + `docs/accessibility/audit.md`. +- The public `/accessibility` route is static content and does not call + the API. + ## Roadmap / status See `ROADMAP.md` in this repo, and Phase 7 in `docforum-core`'s `ROADMAP.md` for the original (pre-split) frontend phase this was carved diff --git a/ROADMAP.md b/ROADMAP.md index 896bcca..4dbace7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,6 +84,12 @@ real API" (no refresh-on-reload flow). a session exists, same caveat as the facility dashboard already had. - [ ] Link transaction hashes/escrows to the Stellar testnet explorer. Tracked as [issue #10](https://github.com/DocForum/docforum-web/issues/10) (Trivial, 100 pts). +## Phase W7 — Platform quality: reliability, accessibility, performance, testing +- [x] [Locale-aware formatting of dates, times, numbers and currency, with RTL readiness](https://github.com/DocForum/docforum-web/issues/88) (High, 200 pts) — `src/i18n/` (`format.ts`, `locale-store.ts`, `useFormat.ts`, `LocaleSync`, `LocaleSwitcher`); every ad-hoc `toLocale*` call replaced; bigint asset amounts never pass through `Number`; appointment times always carry an explicit time zone; CSS converted to logical properties and `` switches with the locale. +- [x] [Poor-network resilience: offline detection, retry policy, and safe mutation retries](https://github.com/DocForum/docforum-web/issues/85) (High, 200 pts) — `onlineManager` wired to browser events, offline banner with one reconnect announcement, reads retried with capped exponential backoff, mutations retried only when an `Idempotency-Key` is present, payment mutations generate one key per user action. See `ARCHITECTURE_ESSENTIALS.md` "Known assumptions — Phase W7". +- [x] [Accessibility audit and fixes to WCAG 2.2 AA: auth and patient flows](https://github.com/DocForum/docforum-web/issues/89) (High, 200 pts) — axe/keyboard audit in `docs/accessibility/audit.md`; accessible dialog primitive with focus trap/restore; error association and focus-to-first-invalid; reduced-motion and target-size fixes; contrast token fixes in the pine/brass palette; [accessibility statement page](/accessibility). Patterns in `docs/accessibility/patterns.md`. +- [x] [Accessibility audit and fixes to WCAG 2.2 AA: doctor, facility and admin flows](https://github.com/DocForum/docforum-web/issues/90) (High, 200 pts) — queue/table captions and scoped headers, batched polite queue announcements, calendar/date-range fieldset semantics, dialog focus management for palette/shortcuts/team modals, non-focusable card cleanup, and admin/payment formatting fixes. + ## Changelog - 2026-09-09 — Roadmap created, carved out of docforum-core Phase 7 as part of the 3-repo org split. @@ -356,5 +362,9 @@ real API" (no refresh-on-reload flow). - `#50 Doctor practice insights`: private insights page (`/doctor/insights`) with weekly slot utilisation, no-show and late-cancellation rates, referral acceptance turnaround, and messages response time. Includes SVG charts paired with accessible semantic `` alternatives, date range selector (presets + custom), clearly labelled small-sample caveats, and strict peer-comparison suppression (doctor's own confidential data only). Wired into `DoctorDashboardPage`, command palette (`⌘K`), and navigation. - `#51 Facility staff accounts & fulfillment attribution`: dedicated team management page (`/facility/team`) with member list, role assignment (`owner` vs `staff`), email invitations, resend/revoke pending invites, member removal with backend session revocation warning, and last-owner deletion protection. Enforced read-only view and action suppression for staff members. In the fulfillment queue (`/facility`), added action attribution display showing which staff member dispensed/fulfilled each order for PRD §7 audit trail. - Updated `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" and `ROADMAP.md`. 24 test files and 112/112 tests passing; typecheck and build green. +- 2026-10-18 — Phase W7 platform-quality pass (#85, #88, #89, #90): + - **#88 i18n/formatting:** added `src/i18n/` with `Intl`-based date/time/relative-time/number/percent/duration helpers and a bigint-safe `formatAssetAmount` (stroops never use `Number`). Replaced every direct `toLocale*` call across auth, patient, doctor, facility and admin screens with `useFormat()`; appointment timestamps always include an explicit time zone. CSS migrated to logical properties, `LocaleSync` drives ``, and a locale switcher (including `ar-EG`) verifies RTL. 16 formatting/direction tests. + - **#85 network resilience:** `api-client.ts` now retries reads with capped exponential backoff and jitter, never retries unkeyed mutations, and reuses one `Idempotency-Key` per mutation attempt across retries. Payment mutations generate keys per action. `onlineManager` is wired to browser events in `main.tsx`; queries pause offline and refetch on reconnect; `OfflineBanner` announces offline/reconnect once. 15 retry/offline tests. + - **#89/#90 accessibility:** added a `Dialog` primitive used by the command palette, shortcuts help, invite/remove-member and data-export modals (focus trap, Escape, focus restore, `aria-modal`). Added table captions and scoped row/column headers, a batched `useAnnouncer` + `LiveRegion` for queue updates, labelled date-range fieldset, skip link, route-change focus, `aria-current` navigation, `aria-invalid`/`aria-describedby` with focus-to-first-error, larger target sizes, and contrast-corrected pine/brass tokens (including dark mode). Added `jest-axe` across auth, queue, facility, insights and the statement page; wrote `docs/accessibility/audit.md` and `docs/accessibility/patterns.md`; added the public `/accessibility` statement. `npm test`, `npm run typecheck` and `npm run build` pass. **Not verified in a real browser/screen reader** — no browser was available in this environment; see the audit's limitations section and the PR. diff --git a/docs/accessibility/audit.md b/docs/accessibility/audit.md new file mode 100644 index 0000000..90a9b53 --- /dev/null +++ b/docs/accessibility/audit.md @@ -0,0 +1,136 @@ +# Accessibility audit — auth, patient, doctor, facility and admin flows + +Issues: [#89](https://github.com/DocForum/docforum-web/issues/89), +[#90](https://github.com/DocForum/docforum-web/issues/90) +Date: 2026-10-18 +Target: WCAG 2.2 level AA (`WCAG22`) + +## Method + +| Check | How it was run | Scope | +| --- | --- | --- | +| Automated rules | `jest-axe` / `axe-core` 4.10 in the Vitest suite (`npm test`) | Every flow below | +| Keyboard-only | Testing Library `user-event`, explicit `keydown` tests, focus assertions | Dialogs, forms, date range, queue actions | +| Structure | Role-based queries (table/rowheader/columnheader/status/dialog) | All data tables, live regions | +| Contrast | Token pairs calculated against WCAG 2.x relative-luminance formula | Pine/brass palette, light and dark | +| Screen reader | **Not run in this environment** — no NVDA/VoiceOver/browser available. Automated names/roles and live-region behaviour are covered; a human pass is still required (see Limitations). | All flows | + +The app was also audited by reading every interactive element, checking +that each one is a native control, has an accessible name, and appears in +a sensible tab order. + +## Checklist and findings + +### Auth — signup, login, claim account (#89) + +| # | Check | Finding | Fix | Regression test | +| --- | --- | --- | --- | --- | +| A1 | Inputs have programmatic labels | Pass (`TextField` wraps a `
` | `
`, `scope="col"`, patient is a row header | `QueuePanel.a11y.test.tsx` | +| D2 | Live queue updates do not spam a screen reader | **Fail** — polled every 30s with no announcement policy | Batched polite announcements (`useAnnouncer`, 800ms) with a count-only summary; action feedback announced separately | `useAnnouncer.test.tsx`, `QueuePanel.a11y.test.tsx` | +| D3 | Row actions have unique names | **Partial** — several "Start"/"Complete" buttons | Names include the patient (`Start visit for Ada Patient`) | `QueuePanel.a11y.test.tsx` | +| D4 | Chart is not the only representation | Pass (table alternative existed) | Removed focusable, non-interactive SVG bars; chart has one accessible name, table is the detail source | `PracticeInsights.a11y.test.tsx` | +| D5 | Dialog focus management (palette / shortcuts) | **Fail** — no trap, no restore, backdrop-only close | Both use `Dialog`; combobox has a real label, `aria-expanded`, `aria-autocomplete` | `CommandPalette.a11y.test.tsx` | +| D6 | Calendar/date range has group semantics | **Fail** — `aria-label` on a `div`, duplicated labels on date inputs | `
`, one label per input, applied-range status | `PracticeInsights.a11y.test.tsx` | +| D7 | Consultation note textarea is labelled | **Fail** — placeholder only | See follow-up note; native `aria-label` added where no visible label exists | axe | +| D8 | Insights tables | Pass (captions/scopes), percentages now localized | — | axe | + +### Facility — fulfillment queue, staff accounts (#90) + +| # | Check | Finding | Fix | Regression test | +| --- | --- | --- | --- | --- | +| F1 | Queue items are not focusable non-interactive divs | **Fail** — `tabIndex={0}` on a card | Cards are `
` with a heading; buttons inside are the only tab stops | axe | +| F2 | Data tables have captions and headers | **Fail** — `aria-label` only | Captions + `scope="col"` + email/name row headers | `FacilityTeam.a11y.test.tsx` | +| F3 | Invite/remove dialogs trap and restore focus | **Fail** — plain dialogs | Both migrated to `Dialog`; Escape closes; focus returns to trigger | `FacilityTeam.a11y.test.tsx` | +| F4 | Destructive action uses a real danger style | **Fail** — inline background/colour | `Button variant="danger"` using tokens; contrast-safe in dark mode | axe | +| F5 | Tabs expose the current page | **Fail** | `aria-current="page"` on the active facility tab | axe | + +### Admin — payments console (#90) + +| # | Check | Finding | Fix | Regression test | +| --- | --- | --- | --- | --- | +| AD1 | Payment lookup field is labelled | Pass (`TextField`) | — | axe | +| AD2 | Status changes are visible and not colour-only | Pass (text status + tx hash) | — | manual | +| AD3 | Buttons are not identified only by position | Pass (Fund/Release/Refund have text names) | — | `PaymentIntentPanel` tests | +| AD4 | Amounts never lose precision | **Fail** — raw stroop strings shown | `formatAssetAmount` formats bigint strings without `Number` | `format.test.ts` | + +### Global shell + +| # | Check | Finding | Fix | +| --- | --- | --- | --- | +| G1 | Skip link | **Fail** | "Skip to main content" link, visible on focus | +| G2 | Route changes move focus | **Fail** | `Layout` focuses `
` on pathname change (not on first paint) | +| G3 | Landmarks/nav names | Partial | `nav aria-label="Primary"`, main landmark with id | +| G4 | Current page indicated | **Fail** | `aria-current="page"` in primary and facility navigation | +| G5 | Offline/reconnect state announced | **Fail** | `OfflineBanner` + `onlineManager` (polite, once on reconnect) | +| G6 | RTL layout | **Fail** | Logical CSS properties + `LocaleSync` sets `dir`/`lang` | +| G7 | Contrast tokens | **Fail** (several) | See table below | + +## Contrast changes (brand-preserving token edits) + +| Token | Before | Ratio | After | Ratio | +| --- | --- | --- | --- | --- | +| `--color-text-faint` (light) | `#8c8271` | 3.52:1 on `#faf6f0` | `#6f6757` | 5.20:1 | +| `--color-accent-2` / `--color-warning` (light) | `#a6763d` | 3.98:1 on white; 3.31:1 on its subtle surface | `#8a5a1f` | 5.89:1 / 4.90:1 | +| `--color-accent` (dark) | `#4a9b76` | 4.25:1 on `--color-accent-subtle` | `#5fae8a` | 5.39:1 | +| `--color-text-faint` (dark) | `#8a7f68` | 4.65:1 on background, lower on raised surfaces | `#9a8f77` | 5.4:1+ | +| White text on brass | `#faf6f0` on `#a6763d` | 3.69:1 | token-based `accent-2-contrast` on `#8a5a1f` | 5.47:1 | + +The pine/brass identity is unchanged — the brass is slightly deeper, and +accent/brass foregrounds now use `*-contrast` tokens so dark mode is +readable too. + +## Regression tests added + +- `src/i18n/format.test.ts`, `src/i18n/LocaleSync.test.tsx` — locale dates, + time zones, bigint amounts, direction. +- `src/services/api-client.test.ts` — retry/idempotency (issue #85). +- `src/hooks/useAnnouncer.test.tsx` — batching, duplicate suppression. +- `src/components/OfflineBanner.test.tsx` — offline/online transitions. +- `src/features/auth/components/LoginForm.a11y.test.tsx` / `SignupForm.a11y.test.tsx` — axe, error association, focus, hints, pending announcements. +- `src/features/queue/components/QueuePanel.a11y.test.tsx` — axe, caption/headers, live region, action names. +- `src/features/facility-team/components/FacilityTeam.a11y.test.tsx` — axe, captions, dialog trap/restore. +- `src/components/CommandPalette.a11y.test.tsx` — axe, combobox label, Escape + focus restore. +- `src/features/insights/components/PracticeInsights.a11y.test.tsx` — axe chart/table, fieldset/legend. +- `src/pages/AccessibilityStatementPage.test.tsx` — axe + honest statements. + +## Limitations / follow-ups + +1. **No human screen-reader pass yet.** Automated rules and keyboard tests + cannot catch every announcement-quality problem (for example whether a + batched queue summary is understandable in context). This must be done + on the deployed preview with NVDA and VoiceOver before claiming full + conformance. Tracked in the PR description. +2. **No real browser screenshot pass.** The environment had no browser + available; layout/RTL and focus visuals were verified from code, CSS and + DOM assertions only. +3. **Browser-level axe run** (axe DevTools) is still worth doing on the + Pages preview for rules that need a real layout engine. +4. Untranslated copy remains English; the locale switcher is a + formatting/direction switch, not a translation framework. diff --git a/docs/accessibility/patterns.md b/docs/accessibility/patterns.md new file mode 100644 index 0000000..6841ab9 --- /dev/null +++ b/docs/accessibility/patterns.md @@ -0,0 +1,157 @@ +# Accessible patterns + +Use these patterns for new screens. They are the ones the audit +(`docs/accessibility/audit.md`) fixed and tests cover. + +## Dialog / modal + +Use `src/components/Dialog.tsx` — never hand-roll `role="dialog"`. + +```tsx + setOpen(false)} + labelledBy="invite-title" + describedBy="invite-description" + overlayClassName={styles.modalOverlay} + panelClassName={styles.modalContent} +> +

Invite team member

+

…

+
+``` + +`Dialog` provides: + +- `role="dialog"` + `aria-modal="true"` and a name from `labelledBy` + (always a visible heading). +- Initial focus on the first focusable element (or the panel). +- Tab/Shift+Tab wrap inside the dialog. +- Escape closes it. +- Focus returns to the element that opened it. +- Backdrop press closes, but only when the press starts on the backdrop. + +Rules: + +- Never nest dialogs. +- Keep one primary action; destructive actions use `Button variant="danger"`. +- Error text goes in `FormError` (`role="alert"`); if a field has an error, + pass it to `TextField` so `aria-describedby`/`aria-invalid` are wired. +- Tests: open from a trigger, assert focus inside, press Escape, assert + focus is back on the trigger. See `FacilityTeam.a11y.test.tsx`. + +## Data tables + +Clinical and queue tables are real `` elements — never grids of +divs with ARIA roles. + +```tsx +
+ + + + + + + + + + + + + +
Today's checked-in patients in queue order
PatientStatus
Ada PatientChecked in
+``` + +- Always a `
` (visually hidden is fine) describing the data. +- `scope="col"` on column headers, `scope="row"` on the row identifier. +- Put the table in a wrapper with `overflow-x: auto` for narrow screens; + do not remove headers to save space. +- If a chart is offered, it must have a `` alternative (see + `UtilisationChart.tsx`). The chart gets one accessible name; its bars are + not focusable. +- Do not make a non-interactive card `tabIndex={0}`. Put the real action in + a button inside it and label the card with a heading (`
`). + +## Calendar and date ranges + +- Appointment timestamps always go through `useFormat()` and + `formatAppointmentTime()` / `formatDateTime()` so they include an + explicit time zone. +- Month names are used for day+month dates. Never render `03/04`. +- Date inputs are native `` inside a + `
`. Give each input exactly one `
- - - - + +

+ Keyboard shortcuts +

+

+ Shortcuts are disabled while typing in a field. Bindings can be + adjusted to avoid clashing with assistive technology. +

+
ActionShortcut
+ + + + + + + + + {DEFAULT_SHORTCUTS.map((b) => ( + + + - - - {DEFAULT_SHORTCUTS.map((b) => ( - - - - - ))} - -
+ Keyboard shortcuts available in the doctor workspace +
ActionShortcut
{b.label}{bindingKey(b.action, bindings)}
{b.label}{bindingKey(b.action, bindings)}
-
- -

- Press ? to reopen help. -

-
+ ))} + +
+
+ +

+ Press ? to reopen help. +

- + ); } diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 633f044..f84fe2a 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -12,8 +12,8 @@ export function Spinner({ size = 16 }: { size?: number }) { height={size} viewBox="0 0 24 24" fill="none" - role="status" aria-hidden="true" + focusable="false" > diff --git a/src/components/TextField.module.css b/src/components/TextField.module.css index 991a9b8..03d94b6 100644 --- a/src/components/TextField.module.css +++ b/src/components/TextField.module.css @@ -13,6 +13,7 @@ .input { padding: 0.7rem 0.875rem; + min-height: var(--target-size); border-radius: var(--radius-sm); border: 1px solid var(--color-border-strong); background: var(--color-bg-raised); @@ -22,12 +23,16 @@ transition: border-color 0.15s ease, box-shadow 0.15s ease; } +/* Placeholder text must also meet 4.5:1 (it is not exempt). */ .input::placeholder { - color: var(--color-text-faint); + color: var(--color-text-muted); + opacity: 1; } +/* Do not remove the outline: the global :focus-visible ring is the + high-contrast indicator. The border/ring is an extra cue, not a + replacement (WCAG 2.4.11). */ .input:focus { - outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px var(--color-accent-subtle); } @@ -41,3 +46,9 @@ color: var(--color-danger); margin: 0; } + +.hint { + font-size: var(--text-xs); + color: var(--color-text-muted); + margin: 0; +} diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index a0eda0d..8783438 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -5,11 +5,16 @@ import styles from './TextField.module.css'; interface TextFieldProps extends InputHTMLAttributes { label: string; error?: string; + /** Persistent guidance, announced alongside an error. */ + hint?: string; } -export function TextField({ label, error, id, ...rest }: TextFieldProps) { +export function TextField({ label, error, hint, id, ...rest }: TextFieldProps) { const generatedId = useId(); const inputId = id ?? generatedId; + const hintId = hint ? `${inputId}-hint` : undefined; + const errorId = error ? `${inputId}-error` : undefined; + const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined; return (
@@ -20,11 +25,16 @@ export function TextField({ label, error, id, ...rest }: TextFieldProps) { id={inputId} className={styles.input} aria-invalid={Boolean(error)} - aria-describedby={error ? `${inputId}-error` : undefined} + aria-describedby={describedBy} {...rest} /> + {hint && ( +

+ {hint} +

+ )} {error && ( - )} diff --git a/src/features/account/components/DataExportSection.module.css b/src/features/account/components/DataExportSection.module.css index 19d5ee2..cd7452f 100644 --- a/src/features/account/components/DataExportSection.module.css +++ b/src/features/account/components/DataExportSection.module.css @@ -39,7 +39,7 @@ .detailsList { margin: var(--space-2) 0 0; - padding-left: var(--space-4); + padding-inline-start: var(--space-4); color: var(--color-text-muted); line-height: var(--leading-snug); } @@ -101,8 +101,7 @@ .modalOverlay { position: fixed; top: 0; - left: 0; - right: 0; + inset-inline: 0; bottom: 0; background: rgba(0, 0, 0, 0.45); display: flex; diff --git a/src/features/account/components/DataExportSection.tsx b/src/features/account/components/DataExportSection.tsx index ab6fd36..23437ef 100644 --- a/src/features/account/components/DataExportSection.tsx +++ b/src/features/account/components/DataExportSection.tsx @@ -3,30 +3,22 @@ import { Button } from '../../../components/Button'; import { TextField } from '../../../components/TextField'; import { FormError } from '../../../components/FormError'; import { Spinner } from '../../../components/Spinner'; +import { Dialog } from '../../../components/Dialog'; import { useDataExport, useDownloadExport, useRequestDataExport } from '../hooks/useDataExport'; +import { useFormat } from '../../../i18n/useFormat'; import styles from './DataExportSection.module.css'; -function formatBytes(bytes?: number | null): string { +function formatBytes(bytes: number | null | undefined, formatNumber: (value: number) => string): string { if (!bytes) return 'Unknown size'; - if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024) return `${formatNumber(bytes)} B`; const kb = bytes / 1024; - if (kb < 1024) return `${kb.toFixed(1)} KB`; - return `${(kb / 1024).toFixed(1)} MB`; -} - -function formatDate(iso?: string | null): string { - if (!iso) return '—'; - try { - return new Date(iso).toLocaleString(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }); - } catch { - return iso; - } + if (kb < 1024) return `${formatNumber(kb)} KB`; + return `${formatNumber(kb / 1024)} MB`; } export function DataExportSection() { + const { formatDateTime, formatNumber, formatDate: formatDay } = useFormat(); + const formatDate = (iso?: string | null): string => (iso ? formatDateTime(iso) : '—'); const { data: exportData, isLoading, error: queryError } = useDataExport(); const requestMutation = useRequestDataExport(); const { download, isDownloading, downloadError } = useDownloadExport(); @@ -136,7 +128,8 @@ export function DataExportSection() { : 'Export failed'} - Requested: {formatDate(exportData.requestedAt)} + Requested:{' '} +
@@ -152,10 +145,19 @@ export function DataExportSection() { {status === 'ready' && (

- Your data export is ready. Download it before it expires on {formatDate(exportData.expiresAt)}. + Your data export is ready. Download it before it expires on{' '} + {formatDay(exportData.expiresAt ?? '')}.

- File size: {formatBytes(exportData.fileSizeBytes)} + + File size:{' '} + {formatBytes(exportData.fileSizeBytes, (value) => + formatNumber(value, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }), + )} + Format: JSON bundle (FHIR-compatible)
- -
- + + + +
+ setPassword(e.target.value)} + autoComplete="current-password" + error={modalError ?? undefined} + required + /> +
+ +
- - )} + +
); } diff --git a/src/features/account/components/DeleteAccountSection.module.css b/src/features/account/components/DeleteAccountSection.module.css index 3703af1..a1ed738 100644 --- a/src/features/account/components/DeleteAccountSection.module.css +++ b/src/features/account/components/DeleteAccountSection.module.css @@ -102,7 +102,7 @@ .policyList { margin: 0; - padding-left: var(--space-4); + padding-inline-start: var(--space-4); font-size: var(--text-xs); color: var(--color-text-muted); line-height: var(--leading-snug); @@ -111,8 +111,7 @@ .modalOverlay { position: fixed; top: 0; - left: 0; - right: 0; + inset-inline: 0; bottom: 0; background: rgba(0, 0, 0, 0.55); display: flex; diff --git a/src/features/auth/components/LoginForm.a11y.test.tsx b/src/features/auth/components/LoginForm.a11y.test.tsx new file mode 100644 index 0000000..d2932e2 --- /dev/null +++ b/src/features/auth/components/LoginForm.a11y.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { render, screen } from '../../../test/render'; +import { LoginForm } from './LoginForm'; + +describe('LoginForm accessibility (#89)', () => { + it('has no detectable axe violations', async () => { + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it('associates validation errors with the field and focuses it', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^log in$/i })); + + const email = screen.getByLabelText(/email/i); + expect(email).toHaveAttribute('aria-invalid', 'true'); + expect(email).toHaveFocus(); + + const errorId = email.getAttribute('aria-describedby'); + expect(errorId).toBeTruthy(); + expect(document.getElementById(errorId!)).toHaveTextContent(/email is required/i); + }); + + it('announces the pending state', async () => { + // Keep the request pending forever: this test only checks the live + // status, and must not hit the network. + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))); + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText(/email/i), 'patient@example.com'); + await user.type(screen.getByLabelText(/password/i), 'password123'); + await user.click(screen.getByRole('button', { name: /^log in$/i })); + + expect(screen.getByRole('status')).toHaveTextContent(/logging in/i); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/features/auth/components/LoginForm.tsx b/src/features/auth/components/LoginForm.tsx index a86e1b4..f26134d 100644 --- a/src/features/auth/components/LoginForm.tsx +++ b/src/features/auth/components/LoginForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { FormEvent } from 'react'; import { TextField } from '../../../components/TextField'; import { Button } from '../../../components/Button'; @@ -17,9 +17,18 @@ export function LoginForm({ onSuccess }: LoginFormProps) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [fieldErrors, setFieldErrors] = useState({}); + const formRef = useRef(null); const loginMutation = useLogin(); const isSlow = useSlowRequestHint(loginMutation.isPending); + // Keyboard and screen-reader users should land on the first problem, + // not have to hunt through the form after an alert fires. + useEffect(() => { + if (fieldErrors.email || fieldErrors.password) { + formRef.current?.querySelector('[aria-invalid="true"]')?.focus(); + } + }, [fieldErrors]); + function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -41,8 +50,13 @@ export function LoginForm({ onSuccess }: LoginFormProps) { : null; return ( -
+ + {loginMutation.isPending && ( +

+ Logging in. Please wait. +

+ )} {isSlow && (

@@ -57,6 +71,7 @@ export function LoginForm({ onSuccess }: LoginFormProps) { value={email} onChange={(e) => setEmail(e.target.value)} error={fieldErrors.email} + required /> setPassword(e.target.value)} error={fieldErrors.password} + required />