fix: better a11y for modal surveys using a focus trap

This commit is contained in:
Javi Aguilar
2026-05-05 16:29:36 +02:00
parent e79753fe3f
commit dd01dbe70a
3 changed files with 54 additions and 5 deletions
@@ -64,7 +64,6 @@ export function RenderSurvey(props: SurveyContainerProps) {
onClose={close}
isOpen={isOpen}
dir={dir}>
{/* @ts-expect-error -- TODO: fix this */}
<Survey
{...props}
clickOutside={hasOverlay ? props.clickOutside : true}
@@ -0,0 +1,44 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, test } from "vitest";
import { SurveyContainer } from "./survey-container";
describe("SurveyContainer", () => {
afterEach(() => {
cleanup();
});
test("marks modal surveys as labelled modal dialogs", () => {
render(
<SurveyContainer mode="modal">
<button>Start</button>
</SurveyContainer>
);
const dialog = screen.getByRole("dialog", { name: "Dialog" });
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
test("does not add dialog semantics to inline surveys", () => {
render(
<SurveyContainer mode="inline">
<button>Start</button>
</SurveyContainer>
);
expect(screen.queryByRole("dialog")).toBeNull();
});
test("wires the modal dialog to the survey content", () => {
render(
<SurveyContainer mode="modal">
<button>Start</button>
</SurveyContainer>
);
const dialog = screen.getByRole("dialog", { name: "Dialog" });
expect(dialog.contains(screen.getByRole("button", { name: "Start" }))).toBe(true);
});
});
@@ -1,12 +1,14 @@
import { useEffect, useRef } from "preact/hooks";
import { type ComponentChildren } from "preact";
import { useEffect } from "preact/hooks";
import { type TOverlay, type TPlacement } from "@formbricks/types/common";
import { useFocusTrap } from "@/lib/use-focus-trap";
import { cn } from "@/lib/utils";
interface SurveyContainerProps {
mode: "modal" | "inline";
placement?: TPlacement;
overlay?: TOverlay;
children: React.ReactNode;
children: ComponentChildren;
onClose?: () => void;
clickOutside?: boolean;
isOpen?: boolean;
@@ -23,8 +25,8 @@ export function SurveyContainer({
isOpen = true,
dir = "auto",
}: Readonly<SurveyContainerProps>) {
const modalRef = useRef<HTMLDivElement>(null);
const isModal = mode === "modal";
const modalRef = useFocusTrap<HTMLDivElement>({ enabled: isModal && isOpen, onEscapeKeyDown: onClose });
const hasOverlay = overlay !== "none";
useEffect(() => {
@@ -47,7 +49,7 @@ export function SurveyContainer({
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [clickOutside, onClose, isModal, isOpen]);
}, [clickOutside, hasOverlay, modalRef, onClose, isModal, isOpen]);
const getPlacementStyle = (placement: TPlacement): string => {
switch (placement) {
@@ -92,6 +94,10 @@ export function SurveyContainer({
)}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label="Dialog"
tabIndex={-1}
className={cn(
getPlacementStyle(placement),
isOpen ? "opacity-100" : "opacity-0",