Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions cypress/e2e/reconnection.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,240 @@ describe("Reconnection", { browser: "!firefox" }, () => {
cy.get(".webchat-chat-history").contains('You said "Hi".');
});
});

/**
* WCAG 2.2 AA coverage for the disconnect (connection-lost) overlay
* (CGY-3269, CGY-3271, CGY-3885).
*
* The mock endpoint never opens a real socket, so the connection state is
* driven through the store (same pattern as closeButtonAnalytics.cy.ts).
*/
describe("Accessibility (WCAG 2.2 AA)", () => {
const setConnected = (connected: boolean) =>
cy.getWebchat().then((webchat: any) => {
webchat.store.dispatch({ type: "SET_CONNECTED", connected });
});

const setReconnectionLimit = (reconnectionLimit: boolean) =>
cy.getWebchat().then((webchat: any) => {
webchat.store.dispatch({ type: "SET_RECONNECTION_LIMIT", reconnectionLimit });
});

/**
* Render the overlay by connecting then dropping. `hadConnection` (WebchatUI
* state) latches asynchronously on the first connect and never resets, so
* retry the connect/drop until the overlay actually renders.
*/
const showDisconnectOverlayViaDrop = (attempt = 0) => {
setConnected(true);
cy.wait(50); // let react-redux commit the connect so hadConnection can latch
setConnected(false);
cy.get("body").then($body => {
const shown = $body.find("[data-disconnect-overlay]").length > 0;
if (!shown && attempt < 20) {
showDisconnectOverlayViaDrop(attempt + 1);
}
});
};

const openChatWithOverlay = () => {
cy.visitWebchat()
.initMockWebchat({
settings: { behavior: { enableConnectionStatusIndicator: true } },
})
.openWebchat()
.startConversation();
showDisconnectOverlayViaDrop();
cy.get("[data-disconnect-overlay]").should("be.visible");
};

it("renders the overlay as a modal dialog with an accessible name", () => {
openChatWithOverlay();

cy.get("[data-disconnect-overlay]")
.should("have.attr", "role", "dialog")
.should("have.attr", "aria-modal", "true")
.should("have.attr", "aria-labelledby", "webchatDisconnectOverlayTitle");
cy.get("#webchatDisconnectOverlayTitle").should("contain.text", "Connection lost");

cy.checkA11yCompliance("[data-cognigy-webchat-root]");
});

it("moves focus once to the close button on open and keeps it stable", () => {
openChatWithOverlay();

cy.get("[data-disconnect-overlay-close-button]").should("have.focus");
// Focus must not be moved again without user action (SC 3.2.1)
cy.wait(500);
cy.get("[data-disconnect-overlay-close-button]").should("have.focus");
});

it("focuses the Reconnect action when the overlay opens in the permanent state", () => {
cy.visitWebchat()
.initMockWebchat({
settings: { behavior: { enableConnectionStatusIndicator: true } },
})
.openWebchat()
.startConversation();
setReconnectionLimit(true);
showDisconnectOverlayViaDrop();

cy.contains("button", "Reconnect").should("be.visible").should("have.focus");
});

it("labels the close button with its real effect and closes the chat window", () => {
openChatWithOverlay();

cy.get("[data-disconnect-overlay-close-button]")
.should("have.attr", "aria-label", "Close chat window")
.click();

// It closes the whole webchat window, and focus returns to the toggle
cy.get("[data-cognigy-webchat]").should("not.exist");
cy.get("[data-cognigy-webchat-toggle]").should("have.focus");
});

it("hides the background chat content from assistive technologies while open", () => {
openChatWithOverlay();

cy.get("[data-disconnect-overlay]")
.parent()
.find("[aria-hidden='true'][inert]")
.should("exist")
.find(".webchat-chat-history")
.should("exist");
});

it("wraps keyboard focus at the overlay's boundaries (focus trap)", () => {
openChatWithOverlay();
setReconnectionLimit(true);

// Two focusable controls: close (X, first) and Reconnect (last).
// Tab on the last wraps to the first; Shift+Tab on the first wraps to
// the last. (In-between moves are native browser tabbing, which
// Cypress cannot synthesize.)
cy.contains("button", "Reconnect").focus().trigger("keydown", { key: "Tab" });
cy.get("[data-disconnect-overlay-close-button]")
.should("have.focus")
.trigger("keydown", { key: "Tab", shiftKey: true });
cy.contains("button", "Reconnect").should("have.focus");
});

it("closes on Escape", () => {
openChatWithOverlay();

cy.get("[data-disconnect-overlay-close-button]").type("{esc}");
cy.get("[data-cognigy-webchat]").should("not.exist");
});

it("announces the gave-up transition and moves focus to the Reconnect action", () => {
openChatWithOverlay();
cy.get("[data-disconnect-overlay-close-button]").should("have.focus");

setReconnectionLimit(true);

// Focus moves to the new primary action — deferred (so the screen
// reader announces the freshly inserted button) and guarded: it only
// happens while focus still sits on the overlay's close button,
// never yanking it from a navigating user. The focus announcement
// conveys the transition, so the live region stays silent here (no
// double announcement).
cy.contains("button", "Reconnect").should("have.focus");
cy.get("[data-cognigy-webchat-root] [role='status'].sr-only").should("have.text", "");
});

it("does not steal focus at the gave-up transition when the user is navigating", () => {
openChatWithOverlay();
cy.get("[data-disconnect-overlay-close-button]").should("have.focus");

setReconnectionLimit(true);
// The user moves focus away before the deferred move fires
cy.get("[data-disconnect-overlay-close-button]").blur();

cy.wait(700);
// Focus was not moved, so the transition is announced via the live
// region instead — exactly one announcement either way.
cy.get("[data-cognigy-webchat-root] [role='status'].sr-only").should(
"have.text",
"Reconnect",
);
cy.contains("button", "Reconnect").should("not.have.focus");
});

it("shows progress and disables Reconnect while an attempt is running", () => {
cy.visitWebchat()
.initMockWebchat({
settings: { behavior: { enableConnectionStatusIndicator: true } },
})
.openWebchat()
.startConversation();
setReconnectionLimit(true);
showDisconnectOverlayViaDrop();

cy.getWebchat().then((webchat: any) => {
webchat.store.dispatch({ type: "SET_CONNECTING", connecting: true });
});

cy.contains("button", "Reconnect").should("have.attr", "aria-disabled", "true");
cy.get(".webchat-disconnect-overlay-status").should("contain.text", "Reconnecting");
});

it("announces a manual reconnection attempt on activation", () => {
cy.visitWebchat()
.initMockWebchat({
settings: { behavior: { enableConnectionStatusIndicator: true } },
})
.openWebchat()
.startConversation();
setReconnectionLimit(true);
showDisconnectOverlayViaDrop();

cy.getWebchat().then((webchat: any) => {
// Keep the attempt pending forever so the announced state is
// stable to assert, and clear any latched `connecting` flag from
// the mock endpoint's initial CONNECT so the click is not a no-op.
webchat.client.connect = () => new Promise(() => {});
webchat.store.dispatch({ type: "SET_CONNECTING", connecting: false });
});

cy.contains("button", "Reconnect").click();

cy.get("[data-cognigy-webchat-root] [role='status'].sr-only").should(
"contain.text",
"Reconnecting",
);
});

it("uses the connection_restored custom translation for the restored announcement", () => {
cy.visitWebchat()
.initMockWebchat({
settings: {
behavior: { enableConnectionStatusIndicator: true },
customTranslations: { connection_restored: "Back online!" },
},
})
.openWebchat()
.startConversation();
showDisconnectOverlayViaDrop();
cy.get("[data-disconnect-overlay]").should("be.visible");

setConnected(true);

cy.get("[data-cognigy-webchat-root] [role='status'].sr-only").should(
"contain.text",
"Back online!",
);
});

it("announces when the connection is restored and the overlay closes", () => {
openChatWithOverlay();

setConnected(true);

cy.get("[data-disconnect-overlay]").should("not.exist");
cy.get("[data-cognigy-webchat-root] [role='status'].sr-only").should(
"contain.text",
"Connection restored.",
);
});
});
16 changes: 16 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,25 @@ WCAG defines _what_ must be true; for _how_ each widget should behave (keyboard
| Visually-hidden text | `.sr-only` class | `src/assets/style.css` |
| Hide/show an offscreen region | tabindex-toggling pattern | `src/webchat-ui/components/presentational/HomeScreen.tsx` |
| Open/close focus orchestration | refs + focus-first-on-open | `src/webchat-ui/components/WebchatUI.tsx` |
| Modal dialog (card or fullscreen) | `<Modal variant>` — APG dialog + focus trap + `aria-modal` | `src/webchat-ui/components/Modal/Modal.tsx` |

User-facing aria strings come from `customTranslations.ariaLabels` — read them with a fallback; never hardcode.

### Pattern: modal dialogs (`Modal`, variant-driven)

All modal surfaces build on `src/webchat-ui/components/Modal/Modal.tsx`, which implements the [APG modal dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) once — `aria-modal="true"`, `aria-labelledby` on the visible title, an Esc handler, and a Tab/Shift+Tab focus trap via `getKeyboardFocusableElements`. The fullscreen variant renders a `div[role="dialog"]` (APG-style): a native `<dialog open>` without `showModal()` has Chromium accessibility quirks where subtree changes inside it (inserted controls, focus on freshly inserted nodes) are not surfaced to screen readers. Pick the variant by what the surface _means_:

- **`variant="card"`** (default) — an inset confirm dialog over a dimmed backdrop (e.g. `DeleteConfirmModal`). Consumers manage initial focus themselves (e.g. `autoFocus` on the safe Cancel action).
- **`variant="fullscreen"`** — the modal represents a blocking state of the whole chat window (e.g. the disconnect overlay). It spans the entire window, so the close button's visual effect (closing the chat window) matches its accessible name. Pass `initialFocusRef` so focus moves **once** on open — to the primary action when rendered, otherwise the close button — and then stays put (SC 3.2.1, 2.4.3).

The disconnect overlay (`DisconnectOverlay.tsx`) additionally:

- Hides the chat layout behind it with `aria-hidden="true"` **and** `inert` (see `DisconnectableContentWrapper` in `WebchatUI.tsx`), so background content is neither announced by screen readers (SC 1.3.1/1.3.2) nor keyboard-reachable. On React 18 `inert` is not a supported prop — it is synced with an inline ref callback. `getKeyboardFocusableElements` skips `inert` subtrees (but deliberately not `aria-hidden` ancestors — the HomeScreen tabindex-toggling pattern queries inside its hidden root), so all focus logic composes with this.
- Announces state **changes** through a persistent visually-hidden `role="status"` region that lives _outside_ the dialog, reusing the already-localized overlay strings (`reconnecting`, `network_error`, `no_network`) — plus one new key, `connection_restored`, announced when the overlay closes (the region outlives the dialog). The dialog's initial appearance is deliberately not announced there (screen readers announce the dialog themselves — a duplicate would be noise). For the same reason the fullscreen variant has no `aria-describedby`: its body contains the status text, which would otherwise be read twice.
- The Reconnect action appears only in the permanent (gave-up) state and receives focus — the focus announcement conveys the transition. The move is **deferred** (~500ms — focusing a control in the same task that inserted it is not announced; the screen reader must ingest the node before the focus event) and **guarded** (only while focus still sits on the overlay's close button — never yanking it from a navigating user, SC 3.2.1). When the guard skips the move, the live region announces the `reconnect` button label instead — exactly one announcement either way, never both. For the same reason, `Modal`'s `initialFocusRef` focus on open is deferred (~200ms) — focusing in the same task that inserted the dialog races the accessibility-tree update and the announcement gets dropped.
- Shows a visible status line while something is happening ("Reconnecting…", "No network connection") and marks the Reconnect button `aria-disabled` (keeping focus, unlike `disabled`) while an attempt is in flight.
- Restores focus on close to the element focused before the overlay opened (Modal does this whenever `initialFocusRef` is used), so a successful reconnect doesn't drop focus to `<body>`.

## Boundary: Webchat vs. chat-components

Message **renderers** (text, image, gallery, list, datepicker, buttons, …) live in the **`@cognigy/chat-components`** package, which Webchat consumes. If an accessibility issue is inside a renderer's internals, fix it upstream in that repo and bump the dependency, rather than patching markup in Webchat.
Expand Down
Loading
Loading