diff --git a/cypress/e2e/reconnection.cy.ts b/cypress/e2e/reconnection.cy.ts index 80d9a9f2..fcacd3ca 100644 --- a/cypress/e2e/reconnection.cy.ts +++ b/cypress/e2e/reconnection.cy.ts @@ -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.", + ); + }); +}); diff --git a/docs/accessibility.md b/docs/accessibility.md index 3b5ebc2e..91e7d432 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -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) | `` — 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 `` 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 ``. + ## 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. diff --git a/docs/embedding.md b/docs/embedding.md index ec3dc045..d440101e 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -426,23 +426,24 @@ With our latest release (v3.22.0), it is now possible to interpolate dynamic val In the above example only the texts outside the `{}` braces needs to be translated. Rest will be handled by interpolation during runtime. Also the position of the dynamic value is changeable within the text -| Name | Type | Default | Description | -| ------------------------------------- | ------ | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| network_error | string | "Connection Lost" | Label shown in the connectivity overlay when a network error occurs. | -| no_network | string | "No network connection" | Label displayed when there is no network connection. | -| reconnect | string | "Reconnect" | Label for the button allowing the user to manually reconnect. | -| reconnecting | string | "Reconnecting..." | Text displayed while trying to re-establish the connection. | -| conversations_options | string | "Conversations Options" | Label for the conversations options menu in the webchat. | -| delete_all_conversations | string | "Delete all conversations" | Label for the button to delete all previous conversations. | -| delete_all_conversations_confirmation | string | "Are you sure you want to delete all previous conversations? This action cannot be undone." | Confirmation message shown when deleting all conversations. | -| delete_conversation | string | "Delete Conversation" | Label for the button to delete the current conversation. | -| delete_conversation_confirmation | string | "Are you sure you want to delete this conversation? This action cannot be undone" | Confirmation message shown when deleting the current conversation. | -| delete | string | "Delete" | Generic label for delete actions. | -| delete_anyway | string | "Delete anyway" | Label for confirming deletion despite warnings. | -| cancel | string | "Cancel" | Label for canceling a delete or confirm action. | -| datePickerMonthLabel | string | "Month" | This text is used as the label for the Month select field in the date-picker. | -| datePickerYearLabel | string | "Year" | This text is used as the label for the Year input field in the date-picker. | -| ariaLabels | object | [Aria Labels](#aria-labels) | Object containing the default ARIA labels for accessible UI elements. | +| Name | Type | Default | Description | +| ------------------------------------- | ------ | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| network_error | string | "Connection Lost" | Label shown in the connectivity overlay when a network error occurs. | +| no_network | string | "No network connection" | Label displayed when there is no network connection. | +| reconnect | string | "Reconnect" | Label for the button allowing the user to manually reconnect. | +| reconnecting | string | "Reconnecting..." | Text displayed while trying to re-establish the connection. | +| connection_restored | string | "Connection restored." | Announced to screen readers when the connection comes back and the connection-lost overlay closes. | +| conversations_options | string | "Conversations Options" | Label for the conversations options menu in the webchat. | +| delete_all_conversations | string | "Delete all conversations" | Label for the button to delete all previous conversations. | +| delete_all_conversations_confirmation | string | "Are you sure you want to delete all previous conversations? This action cannot be undone." | Confirmation message shown when deleting all conversations. | +| delete_conversation | string | "Delete Conversation" | Label for the button to delete the current conversation. | +| delete_conversation_confirmation | string | "Are you sure you want to delete this conversation? This action cannot be undone" | Confirmation message shown when deleting the current conversation. | +| delete | string | "Delete" | Generic label for delete actions. | +| delete_anyway | string | "Delete anyway" | Label for confirming deletion despite warnings. | +| cancel | string | "Cancel" | Label for canceling a delete or confirm action. | +| datePickerMonthLabel | string | "Month" | This text is used as the label for the Month select field in the date-picker. | +| datePickerYearLabel | string | "Year" | This text is used as the label for the Year input field in the date-picker. | +| ariaLabels | object | [Aria Labels](#aria-labels) | Object containing the default ARIA labels for accessible UI elements. | #### Aria Labels @@ -749,6 +750,7 @@ interface IWebchatSettings { no_network?: string; reconnect?: string; reconnecting?: string; + connection_restored?: string; delete_all_conversations?: string; delete_all_conversations_confirmation?: string; delete_conversation?: string; diff --git a/package-lock.json b/package-lock.json index 9f61df42..c2acef21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "moment": "^2.29.4", "react-hot-toast": "^2.4.1", "react-markdown": "^9.0.3", - "react-modal": "^3.16.3", "react-redux": "7.2.9", "react-remove-scroll": "^2.7.1", "react-responsive": "9.0.2", @@ -8525,12 +8524,6 @@ "node": ">=4" } }, - "node_modules/exenv": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", - "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", - "license": "BSD-3-Clause" - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -14572,12 +14565,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -14605,22 +14592,6 @@ "react": ">=18" } }, - "node_modules/react-modal": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz", - "integrity": "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==", - "license": "MIT", - "dependencies": { - "exenv": "^1.2.0", - "prop-types": "^15.7.2", - "react-lifecycles-compat": "^3.0.0", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19", - "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" - } - }, "node_modules/react-player": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/react-player/-/react-player-2.16.0.tgz", @@ -17812,15 +17783,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", diff --git a/package.json b/package.json index e11d89d7..08e243f1 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "moment": "^2.29.4", "react-hot-toast": "^2.4.1", "react-markdown": "^9.0.3", - "react-modal": "^3.16.3", "react-redux": "7.2.9", "react-remove-scroll": "^2.7.1", "react-responsive": "9.0.2", diff --git a/src/common/interfaces/webchat-config.ts b/src/common/interfaces/webchat-config.ts index 1ef93af3..35158e7d 100644 --- a/src/common/interfaces/webchat-config.ts +++ b/src/common/interfaces/webchat-config.ts @@ -312,6 +312,7 @@ export interface IWebchatSettings { no_network: string; reconnect: string; reconnecting: string; + connection_restored?: string; delete_all_conversations: string; delete_all_conversations_confirmation: string; delete_conversation: string; diff --git a/src/webchat-ui/components/Modal/Modal.tsx b/src/webchat-ui/components/Modal/Modal.tsx index a11f818b..c328b1cc 100644 --- a/src/webchat-ui/components/Modal/Modal.tsx +++ b/src/webchat-ui/components/Modal/Modal.tsx @@ -7,6 +7,8 @@ import { Typography } from "@cognigy/chat-components"; import getKeyboardFocusableElements from "../../utils/find-focusable"; import { useSelector } from "../../../webchat/helper/useSelector"; +type ModalVariant = "card" | "fullscreen"; + const Overlay = styled.div({ position: "absolute", top: 0, @@ -20,15 +22,39 @@ const Overlay = styled.div({ zIndex: 9999, }); -const StyledDialog = styled.dialog(({ theme }) => ({ +const StyledDialog = styled.dialog<{ variant: ModalVariant }>(({ theme, variant }) => ({ padding: 20, border: "none", - borderRadius: 16, - width: "90%", - backgroundColor: theme.white, - margin: "20px auto", zIndex: 99999, + ...(variant === "card" + ? { + borderRadius: 16, + width: "90%", + backgroundColor: theme.white, + margin: "20px auto", + } + : { + // "fullscreen" spans the entire webchat window: the modal represents + // a blocking state of the window itself, not a dialog above it. + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + maxWidth: "none", + maxHeight: "none", + margin: 0, + borderRadius: 0, + boxSizing: "border-box", + backgroundColor: theme.backgroundWebchat, + color: theme.textDark, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 24, + }), + "&.fade-enter": { opacity: 0, }, @@ -38,22 +64,30 @@ const StyledDialog = styled.dialog(({ theme }) => ({ }, })); -const ModalHeader = styled.div({ +const ModalHeader = styled.div<{ variant: ModalVariant }>(({ variant }) => ({ display: "flex", alignItems: "center", - position: "relative", + + ...(variant === "card" + ? { position: "relative" } + : { + // Static, so the close button anchors to the dialog's corner and + // the title centers in the window. + position: "static", + "> h3": { fontSize: "1.5rem" }, + }), "> h3": { margin: "auto", textAlign: "center", }, -}); +})); -const CloseButton = styled(IconButton)(({ theme }) => ({ +const CloseButton = styled(IconButton)<{ variant: ModalVariant }>(({ theme, variant }) => ({ color: theme.black10, borderRadius: 4, position: "absolute", - right: -4, + ...(variant === "card" ? { right: -4 } : { right: 15, top: 17 }), "&:focus-visible": { outline: `2px solid ${theme.primaryColorFocus}`, @@ -67,9 +101,17 @@ const CloseButton = styled(IconButton)(({ theme }) => ({ padding: 0, })); -const ModalBody = styled.div({ - marginBottom: 20, -}); +const ModalBody = styled.div<{ variant: ModalVariant }>(({ variant }) => ({ + ...(variant === "card" + ? { marginBottom: 20 } + : { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 24, + textAlign: "center", + }), +})); const ModalFooter = styled.div({ display: "flex", @@ -100,18 +142,52 @@ const Divider = styled.div(({ theme }) => ({ backgroundColor: theme.black80, })); -interface ModalProps { +interface ModalProps extends Omit< + React.DialogHTMLAttributes, + "title" | "onClose" +> { isOpen: boolean; onClose: (state: boolean) => void; title: string; children: React.ReactNode; footer?: React.ReactNode; + /** "card" (default): inset confirm dialog over a dimmed backdrop. + * "fullscreen": spans the whole webchat window (blocking window state). */ + variant?: ModalVariant; + /** Id for the title element / aria-labelledby target. */ + titleId?: string; + /** Accessible name for the close (X) button; defaults to the + * `closeDialog` custom translation. */ + closeButtonAriaLabel?: string; + /** Extra props (e.g. data attributes) for the close (X) button. */ + closeButtonProps?: Omit, "color"> & + Record<`data-${string}`, unknown>; + /** When provided, focus moves on open to this element (falling back to + * the close button when its `current` is not rendered). When omitted, + * the modal does not manage initial focus (consumers may use autoFocus + * on a footer action instead, e.g. DeleteConfirmModal's Cancel). */ + initialFocusRef?: React.RefObject; } -const Modal: React.FC = ({ isOpen, onClose, title, footer, children }) => { +const Modal: React.FC = ({ + isOpen, + onClose, + title, + footer, + children, + variant = "card", + titleId = "modal-title", + closeButtonAriaLabel, + closeButtonProps, + initialFocusRef, + className, + ...restDialogProps +}) => { const dialogRef = useRef(null); + const closeButtonRef = useRef(null); + const previouslyFocusedRef = useRef(null); - const closeButtonAriaLabel = + const defaultCloseButtonAriaLabel = useSelector(state => state.config.settings.customTranslations?.ariaLabels?.closeDialog) ?? "Close dialog"; @@ -119,8 +195,36 @@ const Modal: React.FC = ({ isOpen, onClose, title, footer, children onClose(false); }; + // Move focus once when the dialog opens; it then stays where the user + // puts it (SC 3.2.1, 2.4.3). On close, focus returns to the element that + // was focused before the dialog opened (if it is still in the document — + // e.g. not when closing the dialog closed the whole chat window). Only + // active when the consumer opts in via initialFocusRef — see the prop docs. + useEffect(() => { + if (!initialFocusRef) return; + if (isOpen) { + previouslyFocusedRef.current = document.activeElement as HTMLElement | null; + // Deferred: focusing a control in the same task that inserted it is + // not reliably announced by screen readers — the focus event races + // the accessibility-tree update for the new dialog subtree. + const focusTimer = window.setTimeout(() => { + (initialFocusRef.current ?? closeButtonRef.current)?.focus(); + }, 200); + return () => window.clearTimeout(focusTimer); + } else { + const previouslyFocused = previouslyFocusedRef.current; + previouslyFocusedRef.current = null; + if (previouslyFocused && document.contains(previouslyFocused)) { + previouslyFocused.focus(); + } + } + }, [isOpen, initialFocusRef]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + // Inert means another modal surface (e.g. the disconnect overlay) + // is stacked on top — leave Esc/Tab to that surface. + if (dialogRef.current?.closest("[inert]")) return; if (event.key === "Escape") { handleOnClose(); } @@ -157,35 +261,57 @@ const Modal: React.FC = ({ isOpen, onClose, title, footer, children return ( <> - {isOpen && } + {isOpen && variant === "card" && } without showModal() + // has Chromium a11y quirks where subtree changes inside it + // (inserted controls, focus on new nodes) are not surfaced to + // screen readers. The card variant keeps — its + // centering relies on the element's UA styles. + as={variant === "fullscreen" ? "div" : undefined} + role="dialog" + {...(variant === "card" ? { open: isOpen } : {})} + variant={variant} + aria-modal="true" + aria-labelledby={titleId} + // Only the card variant uses the body as the dialog description — + // the fullscreen body contains a live region (role="status"), + // which would otherwise be announced twice (as description on + // dialog entry and again as a live-region update). + aria-describedby={variant === "card" ? "modal-body" : undefined} ref={dialogRef} + {...restDialogProps} > - + {title} - - - - + {variant === "card" && ( + + + + )} + {children} {footer ? ( diff --git a/src/webchat-ui/components/WebchatUI.tsx b/src/webchat-ui/components/WebchatUI.tsx index 85f11136..9dc573b6 100644 --- a/src/webchat-ui/components/WebchatUI.tsx +++ b/src/webchat-ui/components/WebchatUI.tsx @@ -108,6 +108,7 @@ export interface WebchatUIProps { webchatToggleProps?: React.ComponentProps; connected: boolean; + connecting: boolean; reconnectionLimit: boolean; hasGivenRating: boolean; @@ -211,6 +212,16 @@ const RegularLayoutRoot = styled.div({ overscrollBehavior: "contain", }); +// Wraps the chat layout so it can be hidden from assistive technologies and +// removed from the tab order (aria-hidden + inert) while the disconnect +// overlay is open — the overlay must be the only perceivable/operable content. +const DisconnectableContentWrapper = styled.div({ + height: "100%", + display: "flex", + flexDirection: "column", + minHeight: 0, +}); + const RegularLayoutContentWrapper = styled.div(({ theme }) => ({ height: "100%", zIndex: 3, @@ -1227,13 +1238,29 @@ export class WebchatUI extends React.PureComponent< .chatWindowWidth } > - {!fullscreenMessage - ? this.renderRegularLayout(isInforming) - : this.renderFullscreenMessageLayout()} + { + if (!el) return; + if (showDisconnectOverlay) { + el.setAttribute("inert", ""); + } else { + el.removeAttribute("inert"); + } + }} + > + {!fullscreenMessage + ? this.renderRegularLayout(isInforming) + : this.renderFullscreenMessageLayout()} + @@ -1523,8 +1550,6 @@ export class WebchatUI extends React.PureComponent< /> ); - // ReactModal.setAppElement moved to componentDidMount to avoid repeated invocations. - return ( <> ({ - fontSize: "1.5rem", - marginBlockStart: "-.25rem", - fontWeight: 600, - boxSizing: "border-box", - zIndex: 4, -})); - -const HeaderIconButton = styled(IconButton)(({ theme }) => ({ - position: "absolute", - right: 15, - top: 17, - borderRadius: 4, - "&:focus": { - outline: `2px solid ${theme.primaryColorFocus}`, - outlineOffset: 2, - }, -})); - interface DisconnectOverlayProps { isOpen: boolean; isPermanent: boolean; + isConnecting: boolean; onClose: () => void; onConnect: () => void; config: IWebchatConfig; } +/** + * Blocking connection-lost state of the chat window. A fullscreen Modal so + * closing it visibly means closing the chat window — matching the close + * button's accessible name. The chat layout behind it is aria-hidden + inert + * while open (see DisconnectableContentWrapper in WebchatUI). + */ const DisconnectOverlay = (props: DisconnectOverlayProps) => { - const { isPermanent, onClose, onConnect, config, isOpen } = props; + const { isPermanent, isConnecting, onClose, onConnect, config, isOpen } = props; - const parentSelector = () => document.querySelector("#webchatWindow"); + // Focus the primary Reconnect action on open when it is rendered; + // the Modal falls back to its close button otherwise. + const reconnectRef = useRef(null); - const closeRef = useRef(null); + const showReconnect = isPermanent && navigator.onLine; + const networkErrorText = config.settings.customTranslations?.network_error ?? "Connection lost"; + const reconnectingText = config.settings.customTranslations?.reconnecting ?? "Reconnecting..."; + const noNetworkText = config.settings.customTranslations?.no_network ?? "No network connection"; + const reconnectText = config.settings.customTranslations?.reconnect ?? "Reconnect"; + const connectionRestoredText = + config.settings.customTranslations?.connection_restored ?? "Connection restored."; + + // Live announcements for state *changes*, reusing the already-localized + // overlay strings. This region lives outside the dialog (and outlives it), + // so it can announce "connection restored" after the dialog has closed. + // The dialog's initial appearance is deliberately not announced here — + // screen readers already announce the dialog itself. + const [announcement, setAnnouncement] = useState(""); + const prevStateRef = useRef({ isOpen, isPermanent, isConnecting }); useEffect(() => { - // Hack around stolen autofocus after widget open - setTimeout(() => closeRef.current?.focus(), 200); - }, []); + const prev = prevStateRef.current; + prevStateRef.current = { isOpen, isPermanent, isConnecting }; + + if (!prev.isOpen && isOpen) return; // opening is announced by the dialog + if (prev.isOpen && !isOpen) { + setAnnouncement(connectionRestoredText); + return; + } + if (!isOpen) return; + + if (!prev.isConnecting && isConnecting) { + setAnnouncement(reconnectingText); + } else if (prev.isConnecting && !isConnecting) { + // A manual reconnection attempt ended while still disconnected + setAnnouncement(networkErrorText); + } else if (!prev.isPermanent && isPermanent) { + // Automatic reconnection gave up. + if (!showReconnect) { + setAnnouncement(noNetworkText); + return; + } + // Move focus to the newly appeared Reconnect action. Deferred so + // the screen reader ingests the inserted button before the focus + // event (focusing in the same task the node was inserted is not + // announced), and guarded so it only happens while focus still + // sits on the overlay's close button — never yanking focus from a + // navigating user (SC 3.2.1). The focus move announces the button; + // when the guard skips it, the live region announces the label + // instead, so the transition is announced exactly once either way. + const focusTimer = setTimeout(() => { + const active = document.activeElement; + if (active?.hasAttribute("data-disconnect-overlay-close-button")) { + reconnectRef.current?.focus(); + } else { + setAnnouncement(reconnectText); + } + }, 500); + return () => clearTimeout(focusTimer); + } + }, [ + isOpen, + isPermanent, + isConnecting, + showReconnect, + networkErrorText, + reconnectingText, + noNetworkText, + reconnectText, + connectionRestoredText, + ]); + + const handleReconnect = () => { + if (isConnecting) return; + // Announce the attempt directly on activation — deterministic for + // assistive tech regardless of when (or whether) the store's + // `connecting` flag transition is observed by the effect above. + setAnnouncement(reconnectingText); + onConnect(); + }; + + // Visible status line. The idle permanent state shows no status text (just + // the Reconnect action). + const getStatusText = () => { + if (isConnecting || !isPermanent) return reconnectingText; + if (!navigator.onLine) return noNetworkText; + return null; + }; return ( - - +
+ {announcement} +
+ onClose()} + title={networkErrorText} + titleId="webchatDisconnectOverlayTitle" + closeButtonAriaLabel={ config.settings.customTranslations?.ariaLabels?.closeConnectionWarning ?? - "Close connection lost overlay" + "Close chat window" } + closeButtonProps={{ + "data-disconnect-overlay-close-button": true, + className: "webchat-header-close-button", + }} + initialFocusRef={reconnectRef} + className="webchat-disconnect-overlay" + data-disconnect-overlay > - -
- {config.settings.customTranslations?.network_error ?? "Connection lost"} - {isPermanent ? ( - <> - {navigator.onLine ? ( - - ) : ( -
- {config.settings.customTranslations?.no_network ?? - "No network connection"} -
- )} - - ) : ( -
{config.settings.customTranslations?.reconnecting ?? "Reconnecting..."}
- )} -
+ {getStatusText() && ( +
{getStatusText()}
+ )} + {showReconnect && ( + + )} +
+ ); }; diff --git a/src/webchat-ui/components/presentational/HomeScreen.tsx b/src/webchat-ui/components/presentational/HomeScreen.tsx index 0ad3f8c3..d8f2fe8c 100644 --- a/src/webchat-ui/components/presentational/HomeScreen.tsx +++ b/src/webchat-ui/components/presentational/HomeScreen.tsx @@ -192,7 +192,7 @@ export const HomeScreen: React.FC = props => { useEffect(() => { if (homeScreenRef.current) { const { firstFocusable } = getKeyboardFocusableElements(homeScreenRef.current); - firstFocusable.focus(); + firstFocusable?.focus(); } }, []); diff --git a/src/webchat-ui/utils/find-focusable.ts b/src/webchat-ui/utils/find-focusable.ts index 101ee92c..3bcf7fed 100644 --- a/src/webchat-ui/utils/find-focusable.ts +++ b/src/webchat-ui/utils/find-focusable.ts @@ -10,9 +10,16 @@ const getKeyboardFocusableElements = (element: HTMLElement) => { ); const interactiveElsArray = interactiveEls && Array.from(interactiveEls); - // Filter all the interactive elements that are not disabled or have aria-hidden 'true' + // Filter out interactive elements that are disabled, aria-hidden, or inside + // an inert subtree (e.g. the chat layout behind the disconnect overlay) — + // those cannot receive focus. Ancestor aria-hidden is deliberately NOT + // filtered: the HomeScreen show/hide pattern queries focusables inside its + // aria-hidden root to toggle their tabindex. const focusable = interactiveElsArray?.filter( - el => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"), + el => + !el.hasAttribute("disabled") && + el.getAttribute("aria-hidden") !== "true" && + !el.closest("[inert]"), ); const firstFocusable = focusable && (focusable[0] as HTMLElement); diff --git a/src/webchat/components/ConnectedWebchatUI.tsx b/src/webchat/components/ConnectedWebchatUI.tsx index 330832c3..fe330b11 100644 --- a/src/webchat/components/ConnectedWebchatUI.tsx +++ b/src/webchat/components/ConnectedWebchatUI.tsx @@ -42,6 +42,7 @@ type FromState = Pick< | "fullscreenMessage" | "config" | "connected" + | "connecting" | "reconnectionLimit" >; @@ -70,7 +71,7 @@ export const ConnectedWebchatUI = connect