fix(a11y): disconnect overlay WCAG 2.2 AA compliance (CGY-3269, CGY-3271, CGY-3885) - #297
Open
Sushmitha Sekar (sushmi21) wants to merge 4 commits into
Open
Conversation
…271, CGY-3885)
Rebuild the disconnect overlay on the shared Modal (new fullscreen
variant, replacing react-modal, which is now removed):
- APG modal dialog semantics: role="dialog", aria-modal, aria-labelledby,
Esc, focus trap; chat layout behind the overlay is aria-hidden + inert
- Close button labeled with its real effect ("Close chat window")
- Initial focus moves once on open (deferred so screen readers announce
the freshly inserted dialog) and is restored on close
- State changes announced via a persistent sr-only role="status" region
reusing localized strings, plus new optional customTranslations key
connection_restored for the overlay-closed announcement
- Gave-up transition announced through the live region (a programmatic
focus move to a freshly inserted Reconnect button is not reliably
announced); focus move kept, deferred and guarded against stealing
- Reconnect is aria-disabled with visible status while an attempt runs
- Modals inside an inert subtree ignore Esc/Tab so a stacked overlay
cannot close a background dialog
- getKeyboardFocusableElements skips inert subtrees
Docs updated (accessibility.md pattern, embedding.md setting) and
Cypress WCAG 2.2 AA coverage added for the overlay.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gion) The deferred focus move to the freshly appeared Reconnect button is reliably announced by screen readers, so announcing the label through the live region as well produced a double announcement. The live region now only announces the reconnect label when the guarded focus move is skipped (the user had navigated away) — one announcement either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sushmitha Sekar (sushmi21)
temporarily deployed
to
pr-preview-297
July 30, 2026 06:39 — with
GitHub Actions
Inactive
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the disconnect/reconnect overlay implementation to meet WCAG 2.2 AA by replacing react-modal with an in-house, variant-driven Modal (including focus management, focus trapping, and background isolation), and adds E2E accessibility coverage and documentation for the new pattern.
Changes:
- Introduces
Modalvariants (card/fullscreen) witharia-modal,aria-labelledby, focus trap, and opt-in initial focus + focus restore. - Reworks
DisconnectOverlayto use the fullscreen modal, add a persistentrole="status"live region for state-change announcements, and isolate background content viaaria-hidden+inert. - Removes
react-modal, updates docs/config typings, and adds Cypress WCAG coverage for the overlay.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/webchat/components/ConnectedWebchatUI.tsx | Plumbs connecting state through to the UI to support reconnect progress behavior. |
| src/webchat-ui/utils/find-focusable.ts | Updates focusable-element discovery to exclude elements inside inert subtrees. |
| src/webchat-ui/components/WebchatUI.tsx | Wraps background content to apply aria-hidden + inert while the disconnect overlay is open and passes isConnecting to the overlay. |
| src/webchat-ui/components/presentational/HomeScreen.tsx | Guards initial focus to avoid null deref when no focusable elements exist. |
| src/webchat-ui/components/presentational/DisconnectOverlay.tsx | Rebuilds the overlay using the new fullscreen Modal and adds live status announcements + guarded focus behavior. |
| src/webchat-ui/components/Modal/Modal.tsx | Adds modal variants, aria-modal, configurable labeling, focus trap behavior, and opt-in initial focus + focus restoration. |
| src/common/interfaces/webchat-config.ts | Adds optional connection_restored translation key. |
| package.json | Removes react-modal dependency. |
| package-lock.json | Removes react-modal and its transitive dependencies. |
| docs/embedding.md | Documents connection_restored in the embedding/custom translations docs. |
| docs/accessibility.md | Documents the variant-driven modal pattern and disconnect-overlay a11y rules. |
| cypress/e2e/reconnection.cy.ts | Adds WCAG 2.2 AA Cypress coverage for disconnect overlay semantics, focus, trap, background isolation, and announcements. |
… test - find-focusable: only exclude elements with aria-hidden="true" — the previous truthiness check also filtered aria-hidden="false", which is explicitly not hidden per ARIA (Copilot review finding). - reconnection spec: the mock endpoint's initial CONNECT leaves the `connecting` flag latched true (its connect() never settles), so the manual-attempt test saw no false->true transition and the transition announcement never fired. Reset `connecting` first, with the absent status line as the commit sync point, then start the attempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sushmitha Sekar (sushmi21)
temporarily deployed
to
pr-preview-297
July 30, 2026 09:41 — with
GitHub Actions
Inactive
…sition The connecting announcement relied on the effect observing a false -> true transition of the store's `connecting` flag — fragile against the mock endpoint's latched initial CONNECT (CI-deterministic failure) and, in principle, against any environment where the component mounts with `connecting` already true. The Reconnect click handler now sets the announcement directly on activation; the effect branch remains as a fallback for middleware-initiated reconnects. Split the spec accordingly: aria-disabled + visible progress keep the dispatch-driven test; the announcement is asserted through the real click path with client.connect stubbed pending so the state is stable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Fixes the accessibility findings in the disconnect/reconnect overlay across three tickets, verified with NVDA + keyboard testing:
What changed
Variant-driven Modal.
Modalnow supportsvariant="card"(existing confirm dialog, unchanged forDeleteConfirmModal) andvariant="fullscreen". The disconnect overlay is a fullscreen Modal spanning the whole webchat window, so closing it visibly means closing the chat window — matching its close button's new accessible name "Close chat window" (still overridable viacustomTranslations.ariaLabels.closeConnectionWarning). New opt-in props:titleId,closeButtonAriaLabel,closeButtonProps,initialFocusRef. Modal also gainedaria-modal="true"and focus-restore-on-close.Dialog semantics & focus (CGY-3269).
aria-labelledbypoints at the visible "Connection lost" title. Focus moves once on open (deferred so screen readers announce it) — to Reconnect when rendered, otherwise the close button — replacing the competingautoFocus+setTimeoutthat caused the double focus jump. When automatic reconnection gives up, focus moves to the newly appeared Reconnect button (deferred + guarded — never yanking focus from a navigating user); if the guard skips the move, a live region announces the localizedreconnectlabel instead, so the transition is announced exactly once either way.Focus trap & background isolation (CGY-3885, CGY-3271). The chat layout behind the overlay gets
aria-hidden+inertwhile it is open (DisconnectableContentWrapper), so background content is neither announced nor keyboard-reachable. Modal traps Tab/Shift+Tab at the dialog boundaries viagetKeyboardFocusableElements(which now skipsinertsubtrees); Esc closes. This replaces react-modal, whosesetAppElementwiring was never actually set up — which is why its trap and aria-hiding never worked. react-modal is removed as a dependency (this was its only consumer).Connection-state feedback (from screen-reader testing). A persistent visually-hidden
role="status"region outside the dialog announces state changes using already-localized strings: manual reconnect in progress (reconnecting, with the buttonaria-disabledand dimmed while connecting), attempt failed (network_error), gave up while offline (no_network), and — because the region outlives the dialog — connection restored on close. One new optional translation key:customTranslations.connection_restored(default "Connection restored."). On close, focus returns to the element focused before the overlay opened.Tests & docs
cypress/e2e/reconnection.cy.ts: newAccessibility (WCAG 2.2 AA)describe block — dialog semantics +cy.checkA11yCompliance, initial focus stability, permanent-state focus, truthful close label + close behavior, backgroundaria-hidden/inert, focus trap wrap, Esc, gave-up transition announcement/focus (incl. the no-focus-steal guard), connecting-state feedback, connection-restored announcement.docs/accessibility.md: documents the variant-driven Modal pattern and the hard-won screen-reader rules (noaria-describedbyon bodies containing live regions; deferred + guarded focus for controls inserted into open dialogs).npm run lint:a11ypasses; no new TypeScript errors vsmain(verified by diffing fulltscoutput); production build passes. Cypress E2E runs in CI (cannot run in the local dev environment).How to test
Replace the contents of
dist/index.htmlwith the sample page below (don't commit it), then either run the dev server (npm run devand open http://localhost:8787) or build the widget (npm run build) and serve thedist/folder. The page drives the overlay through all of its states automatically — no network fiddling needed:To re-run a scenario, reload the page — or trigger it on demand from the console:
What to check (NVDA or VoiceOver + keyboard):
aria-disabled("unavailable"); "Reconnecting..." is announced; on success the overlay closes, "Connection restored." is announced, and focus returns to where it was before the disconnect.mainwith the same page to compare.Modal(thecardvariant), so verify it still works and looks unchanged: Home screen → Previous conversations screen → Delete button in the header. Check the dimmed backdrop and centered card render as before, initial focus lands on Cancel, Tab is trapped inside the dialog, Esc/backdrop-click/X close it, and the close button is announced as "Close dialog".Manual verification
Tested with NVDA + Chrome via the harness above (connect → real socket disconnect → gave-up state → manual reconnect): dialog announced with accessible name, single stable focus move, background unreachable by Tab and virtual cursor, gave-up/connecting/restored transitions each announced exactly once, focus restored after reconnect.
🤖 Generated with Claude Code