Skip to content

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
mainfrom
fix/disconnect-overlay-a11y-CGY-3269-3271-3885
Open

fix(a11y): disconnect overlay WCAG 2.2 AA compliance (CGY-3269, CGY-3271, CGY-3885)#297
Sushmitha Sekar (sushmi21) wants to merge 4 commits into
mainfrom
fix/disconnect-overlay-a11y-CGY-3269-3271-3885

Conversation

@sushmi21

@sushmi21 Sushmitha Sekar (sushmi21) commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes the accessibility findings in the disconnect/reconnect overlay across three tickets, verified with NVDA + keyboard testing:

  • CGY-3269 — Reconnect Overlay Findings (SC 3.2.1, 2.4.3, 4.1.2)
  • CGY-3271 — Announcement of background chat content (SC 1.3.1, 1.3.2)
  • CGY-3885 — Focus not trapped in Disconnect Overlay (E.On)

What changed

Variant-driven Modal. Modal now supports variant="card" (existing confirm dialog, unchanged for DeleteConfirmModal) and variant="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 via customTranslations.ariaLabels.closeConnectionWarning). New opt-in props: titleId, closeButtonAriaLabel, closeButtonProps, initialFocusRef. Modal also gained aria-modal="true" and focus-restore-on-close.

Dialog semantics & focus (CGY-3269). aria-labelledby points 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 competing autoFocus + setTimeout that 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 localized reconnect label 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 + inert while it is open (DisconnectableContentWrapper), so background content is neither announced nor keyboard-reachable. Modal traps Tab/Shift+Tab at the dialog boundaries via getKeyboardFocusableElements (which now skips inert subtrees); Esc closes. This replaces react-modal, whose setAppElement wiring 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 button aria-disabled and 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: new Accessibility (WCAG 2.2 AA) describe block — dialog semantics + cy.checkA11yCompliance, initial focus stability, permanent-state focus, truthful close label + close behavior, background aria-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 (no aria-describedby on bodies containing live regions; deferred + guarded focus for controls inserted into open dialogs).

npm run lint:a11y passes; no new TypeScript errors vs main (verified by diffing full tsc output); production build passes. Cypress E2E runs in CI (cannot run in the local dev environment).

How to test

Replace the contents of dist/index.html with the sample page below (don't commit it), then either run the dev server (npm run dev and open http://localhost:8787) or build the widget (npm run build) and serve the dist/ folder. The page drives the overlay through all of its states automatically — no network fiddling needed:

<!doctype html>
<html lang="en">
	<head>
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>disconnect overlay a11y test</title>
	</head>
	<body>
		<script src="./webchat.js"></script>
		<script>
			// 10s after open: the socket is really disconnected -> overlay appears
			// in the transient state ("Reconnecting...").
			// 15s later: the gave-up state is simulated -> Reconnect button appears.
			// Clicking Reconnect genuinely reconnects and closes the overlay.
			// Esc / the X button close the whole chat window (focus -> toggle).
			var OVERLAY_DELAY_MS = 10000;
			var PERMANENT_DELAY_MS = 15000;

			initWebchat(
				"https://endpoint-dev.cognigy.ai/45c4ec61c937e830ecdebfaad977e2ed0bd84001e3b6df736e84560b73506463",
				{
					settings: {
						behavior: {
							// Required for the disconnect overlay to render at all
							enableConnectionStatusIndicator: true,
						},
					},
				},
			).then(function (webchat) {
				window.cognigyWebchat = webchat;
				webchat.open();
				webchat.startConversation();

				setTimeout(function () {
					console.log("[a11y-test] Disconnecting -> transient overlay");
					webchat.disconnect();

					setTimeout(function () {
						console.log("[a11y-test] Simulating gave-up state -> Reconnect button");
						webchat.store.dispatch({
							type: "SET_RECONNECTION_LIMIT",
							reconnectionLimit: true,
						});
					}, PERMANENT_DELAY_MS);
				}, OVERLAY_DELAY_MS);
			});
		</script>
	</body>
</html>

To re-run a scenario, reload the page — or trigger it on demand from the console:

cognigyWebchat.disconnect();
cognigyWebchat.store.dispatch({ type: "SET_RECONNECTION_LIMIT", reconnectionLimit: true });

What to check (NVDA or VoiceOver + keyboard):

  1. Overlay opens (transient): full-window "Connection lost" dialog; screen reader announces the dialog with its accessible name; focus lands on the close (X) button. Tab / virtual cursor must NOT reach the chat history or input behind it (CGY-3885, CGY-3271).
  2. Gave-up state (~15s later): Reconnect button appears; focus moves to it and is announced ("Reconnect, button") — exactly once, no focus ping-pong (CGY-3269).
  3. Activate Reconnect: button dims and reports 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.
  4. Close paths: the X button is announced as "Close chat window" and does exactly that (focus returns to the toggle button); Esc does the same.
  5. Reproduce the original findings on main with the same page to compare.
  6. Regression: Delete Conversation dialog — it is built on the same refactored Modal (the card variant), 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

…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>
Copilot AI review requested due to automatic review settings July 30, 2026 06:39
@snyk-io

snyk-io Bot commented Jul 30, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Modal variants (card / fullscreen) with aria-modal, aria-labelledby, focus trap, and opt-in initial focus + focus restore.
  • Reworks DisconnectOverlay to use the fullscreen modal, add a persistent role="status" live region for state-change announcements, and isolate background content via aria-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.

Comment thread src/webchat-ui/utils/find-focusable.ts
… 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>
…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>
@mboyechko

Mariia Boyechko (mboyechko) commented Jul 31, 2026

Copy link
Copy Markdown

1. "Reconnecting…" not announced by VoiceOver — bug (SC 4.1.3)

Classifying this as a defect under SC 4.1.3 Status Messages (AA). "Reconnecting…" is a status message: it appears, informs the user of progress, and doesn't receive focus — so it must be programmatically exposed and announced without a focus change. NVDA appears to announce it, but only as a side effect of reading the dialog's contents on focus entry; VoiceOver announces the dialog name only, leaving VO users in silence for the whole reconnecting phase. Proposed fix: a persistent visually-hidden role="status" (implicit polite/atomic) container that exists in the DOM before any text is inserted, with the text set in a separate tick (~100ms after mount/focus). Polite is the right politeness level here — if the user is busy, the message queues; no need for role="alert".

For shared context, two related observations from the same test session that are not defects:

2. Programmatic focus move — not a bug

The focus move to the Reconnect button at the gave-up transition is not a WCAG failure. SC 3.2.1/3.2.2 only cover context changes triggered by the user's own focus or input; here the trigger is a system event (reconnection giving up), so at most it touches SC 3.2.5 Change on Request (AAA), which is outside our AA target. It's also defensible UX: focus already sits inside the overlay on the close button, the user has chosen to stay, and the move puts the only new action one keypress away — the focus announcement itself conveys the state change. The implementation is deferred and guarded (only fires while focus is still on the overlay's close button), so it never yanks focus from a navigating user. Classifying as intended behavior, not a defect.

3. NVDA double announcement — not a bug
The doubled output in the NVDA log ("Connection lost / Close chat window, button", then again in reverse order) is not a defect in our code. When focus enters an element with role="dialog", NVDA announces the dialog's accessible name and then reads its content — so the dialog title and the focused close button naturally appear twice: once from the dialog announcement, once from the focus/content pass. This is standard NVDA behavior, not duplicated markup or a duplicate focus event (worth confirming focus() fires only once, but the pattern matches NVDA's dialog reading). VoiceOver shows no duplication on the same flow, which supports this reading. No action needed beyond ensuring we don't also push the dialog title through the live region, which would create a real triple announcement.
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants