Compare commits

..

7 Commits

Author SHA1 Message Date
Javi Aguilar 645613e024 fix code smell 2026-05-05 17:55:42 +02:00
Javi Aguilar e666a81c17 minor optimizations 2026-05-05 17:22:36 +02:00
Javi Aguilar acca5f6cf8 localize the survey dialog aria-label 2026-05-05 17:07:24 +02:00
Javi Aguilar dd01dbe70a fix: better a11y for modal surveys using a focus trap 2026-05-05 16:30:52 +02:00
Javi Aguilar e79753fe3f feat: implement useFocusTrap hook for the preact survey runtime 2026-05-05 16:30:52 +02:00
Javi Aguilar 9a04e95d15 fix: cal and open text fields a11y semantic improvements (#7936) 2026-05-05 12:31:09 +00:00
Bhagya Amarasinghe 9d9f38515d fix: omit replicas when HPA is enabled (#7934) 2026-05-05 10:32:16 +00:00
32 changed files with 640 additions and 98 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ metadata:
{{- end }}
{{- end }}
spec:
{{- if .Values.deployment.replicas }}
{{- if and (not .Values.autoscaling.enabled) (not (kindIs "invalid" .Values.deployment.replicas)) }}
replicas: {{ .Values.deployment.replicas }}
{{- end }}
selector:
+1 -1
View File
@@ -83,7 +83,7 @@ deployment:
# Additional pod annotations
additionalPodAnnotations: {}
# Number of replicas
# Number of replicas when autoscaling is disabled
replicas: 1
# Image pull secrets for private container registries
-1
View File
@@ -96,7 +96,6 @@
"xm-and-surveys/surveys/link-surveys/data-prefilling",
"xm-and-surveys/surveys/link-surveys/embed-surveys",
"xm-and-surveys/surveys/link-surveys/link-settings",
"xm-and-surveys/surveys/link-surveys/pretty-url",
"xm-and-surveys/surveys/link-surveys/personal-links",
"xm-and-surveys/surveys/link-surveys/single-use-links",
"xm-and-surveys/surveys/link-surveys/source-tracking",
@@ -1,81 +0,0 @@
---
title: "Pretty URL"
description: "Create a custom, memorable URL for your survey instead of sharing a long auto-generated link."
icon: "link"
---
<Note>
**Self-Hosted Only**: Pretty URLs are available exclusively on self-hosted Formbricks instances. This feature is not available on Formbricks Cloud.
</Note>
## What is a Pretty URL?
By default, every survey is accessible at a URL containing its auto-generated ID, e.g. `yourdomain.com/s/cm1abc123xyz`. A Pretty URL lets you replace that with a short, human-readable slug of your choice:
```
yourdomain.com/p/customer-feedback
```
When someone visits the pretty URL, they are automatically redirected to the actual survey. Query parameters such as `suId` and `lang` are forwarded as well.
## Setting Up a Pretty URL
<Steps>
<Step title="Open the Share Modal">
Navigate to your survey's **Summary** page and click the **Share survey** button in the top toolbar.
</Step>
<Step title="Go to the Pretty URL tab">
In the Share Modal, select the **Pretty URL** tab.
</Step>
<Step title="Enter a slug">
Type your desired slug in the input field. Slugs may only contain **lowercase letters, numbers, and hyphens** (e.g. `customer-feedback`, `q4-nps-2024`).
The full URL is shown in real time below the input so you can confirm how it will look.
</Step>
<Step title="Save">
Click **Save**. The slug is now live. Anyone visiting the pretty URL is immediately redirected to your survey.
</Step>
</Steps>
## Managing Pretty URLs
Once a slug is saved, the Pretty URL tab shows the active link with two actions:
- **Copy**: copies the full pretty URL to your clipboard.
- **Remove**: deletes the slug (after a confirmation prompt). The survey remains accessible via its original `/s/[surveyId]` URL.
## Viewing All Pretty URLs in Your Organization
All surveys that have a pretty URL assigned are listed in one place:
1. Go to **Organization Settings → Domain**.
2. Open the **Pretty URLs** section.
The table shows each survey's name, workspace, slug, and environment type (production / development).
## Slug Rules
| Rule | Detail |
|------|--------|
| Characters | Lowercase letters (a-z), digits (0-9), and hyphens (-) |
| Uniqueness | Must be unique across your entire Formbricks instance |
| Format example | `customer-feedback`, `onboarding-survey`, `q4-nps` |
## Query Parameter Forwarding
Pretty URLs forward all query parameters to the destination survey URL. For example:
```
/p/customer-feedback?suId=contact123&lang=de
```
redirects to:
```
/s/[surveyId]?suId=contact123&lang=de
```
This means features like [single-use links](/xm-and-surveys/surveys/link-surveys/single-use-links), [data prefilling](/xm-and-surveys/surveys/link-surveys/data-prefilling), and [multi-language surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys) all work with pretty URLs.
@@ -67,12 +67,15 @@ function OpenText({
);
};
const descriptionId = description ? `${inputId}-description` : undefined;
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
descriptionId={descriptionId}
required={required}
requiredLabel={requiredLabel}
htmlFor={inputId}
@@ -90,6 +93,7 @@ function OpenText({
value={value}
onChange={handleChange}
aria-required={required}
aria-describedby={descriptionId}
dir={dir}
rows={rows}
disabled={disabled}
@@ -105,6 +109,7 @@ function OpenText({
value={value}
onChange={handleChange}
aria-required={required}
aria-describedby={descriptionId}
dir={dir}
disabled={disabled}
errorMessage={errorMessage}
@@ -7,6 +7,7 @@ import { cn, stripInlineStyles } from "@/lib/utils";
interface ElementHeaderProps extends React.ComponentProps<"div"> {
headline: string;
description?: string;
descriptionId?: string;
required?: boolean;
/** Custom label for the required indicator. Defaults to "Required" */
requiredLabel?: string;
@@ -44,6 +45,7 @@ const isValidHTML = (str: string): boolean => {
function ElementHeader({
headline,
description,
descriptionId,
required = false,
requiredLabel = "Required",
htmlFor,
@@ -91,7 +93,7 @@ function ElementHeader({
{/* Description/Subheader */}
{description ? (
<Label htmlFor={htmlFor} variant="description">
<Label id={descriptionId} variant="description">
{description}
</Label>
) : null}
+1
View File
@@ -27,6 +27,7 @@
"select_option": "اختر خيارًا",
"select_options": "اختر الخيارات",
"sending_responses": "جارٍ إرسال الردود...",
"survey_dialog": "مربع حوار الاستبيان",
"takes_less_than_x_minutes": "{count, plural, zero {يستغرق أقل من دقيقة} one {يستغرق أقل من دقيقة واحدة} two {يستغرق أقل من دقيقتين} few {يستغرق أقل من {count} دقائق} many {يستغرق أقل من {count} دقيقة} other {يستغرق أقل من {count} دقيقة}}",
"takes_x_minutes": "{count, plural, zero {يستغرق صفر دقائق} one {يستغرق دقيقة واحدة} two {يستغرق دقيقتين} few {يستغرق {count} دقائق} many {يستغرق {count} دقيقة} other {يستغرق {count} دقيقة}}",
"takes_x_plus_minutes": "يستغرق {count}+ دقيقة",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Vælg en mulighed",
"select_options": "Vælg muligheder",
"sending_responses": "Sender svar…",
"survey_dialog": "Undersøgelsesdialog",
"takes_less_than_x_minutes": "{count, plural, one {Tager mindre end 1 minut} other {Tager mindre end {count} minutter}}",
"takes_x_minutes": "{count, plural, one {Tager 1 minut} other {Tager {count} minutter}}",
"takes_x_plus_minutes": "Tager {count}+ minutter",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Wähle eine Option",
"select_options": "Wähle Optionen",
"sending_responses": "Antworten werden gesendet...",
"survey_dialog": "Umfragedialog",
"takes_less_than_x_minutes": "{count, plural, one {Dauert weniger als 1 Minute} other {Dauert weniger als {count} Minuten}}",
"takes_x_minutes": "{count, plural, one {Dauert 1 Minute} other {Dauert {count} Minuten}}",
"takes_x_plus_minutes": "Dauert {count}+ Minuten",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Select an option",
"select_options": "Select options",
"sending_responses": "Sending responses…",
"survey_dialog": "Survey Dialog",
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
"takes_x_plus_minutes": "Takes {count}+ minutes",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Selecciona una opción",
"select_options": "Selecciona opciones",
"sending_responses": "Enviando respuestas...",
"survey_dialog": "Diálogo de encuesta",
"takes_less_than_x_minutes": "{count, plural, one {Toma menos de 1 minuto} other {Toma menos de {count} minutos}}",
"takes_x_minutes": "{count, plural, one {Toma 1 minuto} other {Toma {count} minutos}}",
"takes_x_plus_minutes": "Toma {count}+ minutos",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Vali variant",
"select_options": "Vali variandid",
"sending_responses": "Vastuste saatmine…",
"survey_dialog": "Küsitluse dialoog",
"takes_less_than_x_minutes": "{count, plural, one {Võtab vähem kui 1 minuti} other {Võtab vähem kui {count} minutit}}",
"takes_x_minutes": "{count, plural, one {Võtab 1 minuti} other {Võtab {count} minutit}}",
"takes_x_plus_minutes": "Võtab {count}+ minutit",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Sélectionner une option",
"select_options": "Sélectionner des options",
"sending_responses": "Envoi des réponses...",
"survey_dialog": "Boîte de dialogue du sondage",
"takes_less_than_x_minutes": "{count, plural, one {Prend moins d'une minute} other {Prend moins de {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Prend 1 minute} other {Prend {count} minutes}}",
"takes_x_plus_minutes": "Prend {count}+ minutes",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "एक विकल्प चुनें",
"select_options": "विकल्प चुनें",
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
"survey_dialog": "सर्वेक्षण संवाद",
"takes_less_than_x_minutes": "{count, plural, one {1 मिनट से कम लगता है} other {{count} मिनट से कम लगता है}}",
"takes_x_minutes": "{count, plural, one {1 मिनट लगता है} other {{count} मिनट लगते हैं}}",
"takes_x_plus_minutes": "{count}+ मिनट लगते हैं",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Lehetőség kiválasztása",
"select_options": "Lehetőségek kiválasztása",
"sending_responses": "Válaszok küldése…",
"survey_dialog": "Kérdőív párbeszédpanel",
"takes_less_than_x_minutes": "{count, plural, one {Kevesebb mint 1 percet vesz igénybe} other {Kevesebb mint {count} percet vesz igénybe}}",
"takes_x_minutes": "{count, plural, one {1 percet vesz igénybe} other {{count} percet vesz igénybe}}",
"takes_x_plus_minutes": "{count}+ percet vesz igénybe",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Seleziona un'opzione",
"select_options": "Seleziona opzioni",
"sending_responses": "Invio risposte in corso...",
"survey_dialog": "Finestra di dialogo del sondaggio",
"takes_less_than_x_minutes": "{count, plural, one {Richiede meno di 1 minuto} other {Richiede meno di {count} minuti}}",
"takes_x_minutes": "{count, plural, one {Richiede 1 minuto} other {Richiede {count} minuti}}",
"takes_x_plus_minutes": "Richiede più di {count} minuti",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "オプションを選択",
"select_options": "オプションを選択",
"sending_responses": "回答を送信中...",
"survey_dialog": "アンケートダイアログ",
"takes_less_than_x_minutes": "{count, plural, one {1分未満} other {{count}分未満}}",
"takes_x_minutes": "{count, plural, one {1分} other {{count}分}}",
"takes_x_plus_minutes": "{count}分以上",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Selecteer een optie",
"select_options": "Selecteer opties",
"sending_responses": "Reacties verzenden...",
"survey_dialog": "Enquête-dialoogvenster",
"takes_less_than_x_minutes": "{count, plural, one {Duurt minder dan 1 minuut} other {Duurt minder dan {count} minuten}}",
"takes_x_minutes": "{count, plural, one {Duurt 1 minuut} other {Duurt {count} minuten}}",
"takes_x_plus_minutes": "Duurt {count}+ minuten",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Selecione uma opção",
"select_options": "Selecione opções",
"sending_responses": "Enviando respostas...",
"survey_dialog": "Caixa de diálogo da pesquisa",
"takes_less_than_x_minutes": "{count, plural, one {Leva menos de 1 minuto} other {Leva menos de {count} minutos}}",
"takes_x_minutes": "{count, plural, one {Leva 1 minuto} other {Leva {count} minutos}}",
"takes_x_plus_minutes": "Leva {count}+ minutos",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Selectează o opțiune",
"select_options": "Selectează opțiuni",
"sending_responses": "Trimiterea răspunsurilor...",
"survey_dialog": "Dialog sondaj",
"takes_less_than_x_minutes": "{count, plural, one {Durează mai puțin de 1 minut} few {Durează mai puțin de {count} minute} other {Durează mai puțin de {count} de minute}}",
"takes_x_minutes": "{count, plural, one {Durează 1 minut} few {Durează {count} minute} other {Durează {count} de minute}}",
"takes_x_plus_minutes": "Durează peste {count} minute",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Выбери вариант",
"select_options": "Выбери варианты",
"sending_responses": "Отправка ответов...",
"survey_dialog": "Диалог опроса",
"takes_less_than_x_minutes": "{count, plural, one {Займёт меньше 1 минуты} few {Займёт меньше {count} минут} many {Займёт меньше {count} минут} other {Займёт меньше {count} минуты}}",
"takes_x_minutes": "{count, plural, one {Займёт 1 минуту} few {Займёт {count} минуты} many {Займёт {count} минут} other {Займёт {count} минуты}}",
"takes_x_plus_minutes": "Займёт {count}+ минут",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Välj ett alternativ",
"select_options": "Välj alternativ",
"sending_responses": "Skickar svar...",
"survey_dialog": "Enkätdialog",
"takes_less_than_x_minutes": "{count, plural, one {Tar mindre än 1 minut} other {Tar mindre än {count} minuter}}",
"takes_x_minutes": "{count, plural, one {Tar 1 minut} other {Tar {count} minuter}}",
"takes_x_plus_minutes": "Tar {count}+ minuter",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Bir seçenek seçin",
"select_options": "Seçenekleri seçin",
"sending_responses": "Yanıtlar gönderiliyor…",
"survey_dialog": "Anket iletişim kutusu",
"takes_less_than_x_minutes": "{count, plural, one {1 dakikadan az sürer} other {{count} dakikadan az sürer}}",
"takes_x_minutes": "{count, plural, one {1 dakika sürer} other {{count} dakika sürer}}",
"takes_x_plus_minutes": "{count}+ dakika sürer",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "Variantni tanla",
"select_options": "Variantlarni tanla",
"sending_responses": "Javoblar yuborilmoqda...",
"survey_dialog": "Sorovnoma dialog oynasi",
"takes_less_than_x_minutes": "{count, plural, one {1 daqiqadan kam vaqt oladi} other {{count} daqiqadan kam vaqt oladi}}",
"takes_x_minutes": "{count, plural, one {1 daqiqa vaqt oladi} other {{count} daqiqa vaqt oladi}}",
"takes_x_plus_minutes": "{count}+ daqiqa vaqt oladi",
+1
View File
@@ -27,6 +27,7 @@
"select_option": "请选择一个选项",
"select_options": "请选择多个选项",
"sending_responses": "正在发送响应...",
"survey_dialog": "调查对话框",
"takes_less_than_x_minutes": "{count, plural, one {少于 1 分钟} other {少于 {count} 分钟}}",
"takes_x_minutes": "{count, plural, one {1 分钟} other {{count} 分钟}}",
"takes_x_plus_minutes": "{count}+ 分钟",
@@ -63,7 +63,11 @@ export function CalElement({
elementId={element.id}
/>
<CalEmbed key={element.id} element={element} onSuccessfulBooking={onSuccessfulBooking} />
{errorMessage ? <span className="text-red-500">{errorMessage}</span> : null}
{errorMessage ? (
<span className="text-red-500" role="alert" aria-live="assertive" aria-atomic="true">
{errorMessage}
</span>
) : null}
</div>
</form>
);
@@ -61,7 +61,7 @@ export function OpenTextElement({
<form key={element.id} onSubmit={handleOnSubmit} className="w-full">
<OpenText
elementId={element.id}
inputId={element.id}
inputId={`${element.id}-input`}
headline={getLocalizedValue(element.headline, languageCode)}
description={element.subheader ? getLocalizedValue(element.subheader, languageCode) : undefined}
placeholder={getLocalizedValue(element.placeholder, languageCode)}
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { isRTLLanguage } from "@/lib/utils";
import { SurveyContainer } from "../wrappers/survey-container";
@@ -8,6 +8,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
const [isOpen, setIsOpen] = useState(true);
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { onClose } = props;
const isRTL = isRTLLanguage(props.survey, props.languageCode);
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "ltr");
@@ -17,7 +18,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only recalculate direction when languageCode changes, not on survey auto-save
}, [props.languageCode]);
const close = () => {
const close = useCallback(() => {
if (onFinishedTimeoutRef.current) {
clearTimeout(onFinishedTimeoutRef.current);
onFinishedTimeoutRef.current = null;
@@ -31,11 +32,9 @@ export function RenderSurvey(props: SurveyContainerProps) {
setIsOpen(false);
closeTimeoutRef.current = setTimeout(() => {
if (props.onClose) {
props.onClose();
}
onClose?.();
}, 1000);
};
}, [onClose]);
useEffect(() => {
return () => {
@@ -64,7 +63,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,50 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SurveyContainer } from "./survey-container";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => (key === "common.survey_dialog" ? "Survey Dialog" : key),
}),
}));
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: "Survey 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: "Survey Dialog" });
expect(dialog.contains(screen.getByRole("button", { name: "Start" }))).toBe(true);
});
});
@@ -1,12 +1,15 @@
import { useEffect, useRef } from "preact/hooks";
import { type ComponentChildren } from "preact";
import { useEffect } from "preact/hooks";
import { useTranslation } from "react-i18next";
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 +26,9 @@ export function SurveyContainer({
isOpen = true,
dir = "auto",
}: Readonly<SurveyContainerProps>) {
const modalRef = useRef<HTMLDivElement>(null);
const isModal = mode === "modal";
const { t } = useTranslation();
const modalRef = useFocusTrap<HTMLDivElement>({ enabled: isModal && isOpen, onEscapeKeyDown: onClose });
const hasOverlay = overlay !== "none";
useEffect(() => {
@@ -47,7 +51,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 +96,10 @@ export function SurveyContainer({
)}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("common.survey_dialog")}
tabIndex={-1}
className={cn(
getPlacementStyle(placement),
isOpen ? "opacity-100" : "opacity-0",
@@ -0,0 +1,266 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { type ComponentChildren } from "preact";
import { afterEach, describe, expect, test, vi } from "vitest";
import { useFocusTrap } from "./use-focus-trap";
const FocusTrapFixture = ({
children,
enabled = true,
onEscapeKeyDown,
withTabIndex = true,
}: {
children: ComponentChildren;
enabled?: boolean;
onEscapeKeyDown?: () => void;
withTabIndex?: boolean;
}) => {
const focusTrapRef = useFocusTrap<HTMLDivElement>({ enabled, onEscapeKeyDown });
return (
<>
<button>Host page button</button>
<div ref={focusTrapRef} tabIndex={withTabIndex ? -1 : undefined}>
{children}
</div>
</>
);
};
const FocusTrapUnmountFixture = ({
showTrap,
onEscapeKeyDown,
}: {
showTrap: boolean;
onEscapeKeyDown?: () => void;
}) => (
<>
<button>External host button</button>
{showTrap ? (
<FocusTrapFixture onEscapeKeyDown={onEscapeKeyDown}>
<button>Survey action</button>
</FocusTrapFixture>
) : null}
</>
);
describe("useFocusTrap", () => {
afterEach(() => {
cleanup();
});
test("focuses the first tabbable element when active", async () => {
render(
<FocusTrapFixture>
<button>First action</button>
<button>Last action</button>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "First action" }));
});
});
test("makes the trap root focusable when it has no tabIndex", async () => {
render(
<FocusTrapFixture withTabIndex={false}>
<span>Static content</span>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement?.getAttribute("tabindex")).toBe("-1");
});
});
test("allows links to receive initial focus", async () => {
render(
<FocusTrapFixture>
<a href="https://formbricks.com">Formbricks link</a>
<button>Survey action</button>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("link", { name: "Formbricks link" }));
});
});
test("keeps tab focus inside the trap", async () => {
render(
<FocusTrapFixture>
<button>First action</button>
<button>Last action</button>
</FocusTrapFixture>
);
const firstButton = screen.getByRole("button", { name: "First action" });
const lastButton = screen.getByRole("button", { name: "Last action" });
await waitFor(() => {
expect(document.activeElement).toBe(firstButton);
});
fireEvent.keyDown(document, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(lastButton);
fireEvent.keyDown(document, { key: "Tab" });
expect(document.activeElement).toBe(firstButton);
});
test("keeps focus from moving outside the trap", async () => {
render(
<FocusTrapFixture>
<button>Survey action</button>
</FocusTrapFixture>
);
const trappedButton = screen.getByRole("button", { name: "Survey action" });
const hostPageButton = screen.getByRole("button", { name: "Host page button" });
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
hostPageButton.focus();
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
});
test("calls the Escape handler when provided", async () => {
const handleEscapeKeyDown = vi.fn();
render(
<FocusTrapFixture onEscapeKeyDown={handleEscapeKeyDown}>
<button>Survey action</button>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Survey action" }));
});
fireEvent.keyDown(document, { key: "Escape" });
expect(handleEscapeKeyDown).toHaveBeenCalledTimes(1);
});
test("restores focus to the previously focused element on unmount", async () => {
const initialEscapeHandler = vi.fn();
const updatedEscapeHandler = vi.fn();
const { rerender } = render(
<FocusTrapUnmountFixture showTrap={false} onEscapeKeyDown={initialEscapeHandler} />
);
const hostButton = screen.getByRole("button", { name: "External host button" });
hostButton.focus();
rerender(<FocusTrapUnmountFixture showTrap={true} onEscapeKeyDown={initialEscapeHandler} />);
const trappedButton = screen.getByRole("button", { name: "Survey action" });
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
rerender(<FocusTrapUnmountFixture showTrap={true} onEscapeKeyDown={updatedEscapeHandler} />);
rerender(<FocusTrapUnmountFixture showTrap={false} onEscapeKeyDown={updatedEscapeHandler} />);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "External host button" }));
});
});
test("re-traps focus when focusout has no related target", async () => {
render(
<FocusTrapFixture>
<button>Survey action</button>
</FocusTrapFixture>
);
const trappedButton = screen.getByRole("button", { name: "Survey action" });
const hostPageButton = screen.getByRole("button", { name: "Host page button" });
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
fireEvent.focusOut(trappedButton, { relatedTarget: null });
hostPageButton.focus();
await waitFor(() => {
expect(document.activeElement).toBe(trappedButton);
});
});
test("falls back to a connected element when the last focused node was removed", async () => {
const { rerender } = render(
<FocusTrapFixture>
<button>First action</button>
<button>Last action</button>
</FocusTrapFixture>
);
const firstButton = screen.getByRole("button", { name: "First action" });
const lastButton = screen.getByRole("button", { name: "Last action" });
const hostPageButton = screen.getByRole("button", { name: "Host page button" });
await waitFor(() => {
expect(document.activeElement).toBe(firstButton);
});
lastButton.focus();
await waitFor(() => {
expect(document.activeElement).toBe(lastButton);
});
rerender(
<FocusTrapFixture>
<button>First action</button>
</FocusTrapFixture>
);
hostPageButton.focus();
await waitFor(() => {
expect(document.activeElement).toBe(firstButton);
});
});
test("skips disabled, hidden, and inert candidates", async () => {
render(
<FocusTrapFixture>
<button disabled>Disabled action</button>
<button hidden>Hidden action</button>
<div
ref={(element) => {
element?.setAttribute("inert", "");
}}>
<button>Inert action</button>
</div>
<button>Enabled action</button>
</FocusTrapFixture>
);
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Enabled action" }));
});
});
test("does not move focus when inactive", async () => {
render(
<FocusTrapFixture enabled={false}>
<button>Survey action</button>
</FocusTrapFixture>
);
await Promise.resolve();
expect(document.activeElement).toBe(document.body);
});
});
+272
View File
@@ -0,0 +1,272 @@
import { type MutableRef, useEffect, useRef } from "preact/hooks";
type FocusScope = { paused: boolean; pause: () => void; resume: () => void };
type FocusableTarget = HTMLElement | { focus: (options?: FocusOptions) => void };
type UseFocusTrapOptions = {
enabled: boolean;
onEscapeKeyDown?: () => void;
};
// focus trap behavior adapted from Radix UI FocusScope (MIT) for this Preact runtime.
const focusScopesStack = (() => {
let stack: FocusScope[] = [];
const remove = (focusScope: FocusScope) => stack.filter((scope) => scope !== focusScope);
return {
add: (focusScope: FocusScope) => {
const activeFocusScope = stack[0];
if (focusScope !== activeFocusScope) {
activeFocusScope?.pause();
}
stack = remove(focusScope);
stack.unshift(focusScope);
},
remove: (focusScope: FocusScope) => {
stack = remove(focusScope);
stack[0]?.resume();
},
};
})();
const focus = (element?: FocusableTarget | null, { select = false } = {}) => {
if (!element?.focus) return;
const previouslyFocusedElement = document.activeElement;
element.focus({ preventScroll: true });
if (
element !== previouslyFocusedElement &&
element instanceof HTMLInputElement &&
"select" in element &&
select
) {
element.select();
}
};
const focusFirst = (candidates: HTMLElement[], { select = false } = {}) => {
const previouslyFocusedElement = document.activeElement;
for (const candidate of candidates) {
focus(candidate, { select });
if (document.activeElement !== previouslyFocusedElement) return;
}
};
const isHidden = (node: HTMLElement, upTo: HTMLElement) => {
if (getComputedStyle(node).visibility === "hidden") return true;
let currentNode: HTMLElement | null = node;
while (currentNode) {
if (currentNode === upTo) return false;
if (getComputedStyle(currentNode).display === "none") return true;
currentNode = currentNode.parentElement;
}
return false;
};
const isDisabledFormControl = (element: HTMLElement) =>
(element instanceof HTMLButtonElement ||
element instanceof HTMLInputElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLOptGroupElement ||
element instanceof HTMLOptionElement ||
element instanceof HTMLFieldSetElement) &&
element.disabled;
const getTabbableCandidates = (container: HTMLElement) => {
const nodes: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node) => {
const element = node as HTMLElement;
const isHiddenInput = element.tagName === "INPUT" && (element as HTMLInputElement).type === "hidden";
if (element.closest("[inert]")) return NodeFilter.FILTER_REJECT;
if (element.closest("fieldset[disabled]")) return NodeFilter.FILTER_REJECT;
if (element.hidden || isHidden(element, container)) return NodeFilter.FILTER_REJECT;
if (isDisabledFormControl(element) || isHiddenInput) return NodeFilter.FILTER_SKIP;
return element.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
while (walker.nextNode()) {
nodes.push(walker.currentNode as HTMLElement);
}
return nodes;
};
const getTabbableEdges = (container: HTMLElement) => {
const candidates = getTabbableCandidates(container);
const first = candidates[0];
const last = candidates.at(-1);
return [first, last] as const;
};
export const useFocusTrap = <TElement extends HTMLElement>({
enabled,
onEscapeKeyDown,
}: UseFocusTrapOptions): MutableRef<TElement | null> => {
const containerRef = useRef<TElement>(null);
const lastFocusedElementRef = useRef<HTMLElement | null>(null);
const onEscapeKeyDownRef = useRef(onEscapeKeyDown);
const focusScopeRef = useRef<FocusScope>({
paused: false,
pause() {
this.paused = true;
},
resume() {
this.paused = false;
},
});
useEffect(() => {
// Keep the latest escape handler without re-running the main trap effect.
onEscapeKeyDownRef.current = onEscapeKeyDown;
}, [onEscapeKeyDown]);
useEffect(() => {
if (!enabled) return;
const container = containerRef.current;
if (!container) return;
const focusScope = focusScopeRef.current;
const previouslyFocusedElement = document.activeElement as HTMLElement | null;
const previousTabIndex = container.getAttribute("tabindex");
let isUnmounting = false;
if (previousTabIndex === null) {
container.setAttribute("tabindex", "-1");
}
focusScopesStack.add(focusScope);
if (!container.contains(previouslyFocusedElement)) {
focusFirst(getTabbableCandidates(container), { select: true });
if (document.activeElement === previouslyFocusedElement) {
focus(container);
}
}
if (container.contains(document.activeElement)) {
lastFocusedElementRef.current = document.activeElement as HTMLElement;
}
const focusLastElementInsideContainer = () => {
const [firstFocusableElement] = getTabbableEdges(container);
const lastFocusedElement =
lastFocusedElementRef.current && container.contains(lastFocusedElementRef.current)
? lastFocusedElementRef.current
: null;
focus(lastFocusedElement ?? firstFocusableElement ?? container, { select: true });
};
const handleFocusIn = (event: FocusEvent) => {
if (focusScope.paused) return;
const target = event.target as HTMLElement | null;
if (target && container.contains(target)) {
lastFocusedElementRef.current = target;
return;
}
focusLastElementInsideContainer();
};
const handleFocusOut = (event: FocusEvent) => {
if (focusScope.paused) return;
const relatedTarget = event.relatedTarget as HTMLElement | null;
if (relatedTarget && !container.contains(relatedTarget)) {
focusLastElementInsideContainer();
return;
}
if (relatedTarget === null) {
setTimeout(() => {
if (!isUnmounting && !container.contains(document.activeElement)) {
focusLastElementInsideContainer();
}
}, 0);
}
};
const handleMutations = () => {
if (!container.contains(document.activeElement)) {
focusLastElementInsideContainer();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (focusScope.paused) return;
const hasModifierKey = event.altKey || event.ctrlKey || event.metaKey;
if (event.key === "Escape" && !hasModifierKey && onEscapeKeyDownRef.current) {
event.preventDefault();
onEscapeKeyDownRef.current();
return;
}
const isTabKey = event.key === "Tab" && !event.altKey && !event.ctrlKey && !event.metaKey;
if (!isTabKey) return;
const focusedElement = document.activeElement as HTMLElement | null;
const [firstFocusableElement, lastFocusableElement] = getTabbableEdges(container);
if (!firstFocusableElement || !lastFocusableElement) {
if (focusedElement === container) {
event.preventDefault();
}
return;
}
if (!event.shiftKey && focusedElement === lastFocusableElement) {
event.preventDefault();
focus(firstFocusableElement, { select: true });
return;
}
if (event.shiftKey && focusedElement === firstFocusableElement) {
event.preventDefault();
focus(lastFocusableElement, { select: true });
}
};
document.addEventListener("focusin", handleFocusIn);
document.addEventListener("focusout", handleFocusOut);
document.addEventListener("keydown", handleKeyDown);
const mutationObserver = new MutationObserver(handleMutations);
mutationObserver.observe(container, { childList: true, subtree: true });
return () => {
isUnmounting = true;
document.removeEventListener("focusin", handleFocusIn);
document.removeEventListener("focusout", handleFocusOut);
document.removeEventListener("keydown", handleKeyDown);
mutationObserver.disconnect();
focusScopesStack.remove(focusScope);
if (previousTabIndex === null) {
container.removeAttribute("tabindex");
}
setTimeout(() => {
if (previouslyFocusedElement?.isConnected) {
focus(previouslyFocusedElement, { select: true });
}
}, 0);
};
}, [enabled]);
return containerRef;
};