mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-05 19:30:48 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 645613e024 | |||
| e666a81c17 | |||
| acca5f6cf8 | |||
| dd01dbe70a | |||
| e79753fe3f | |||
| 9a04e95d15 | |||
| 9d9f38515d |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}+ دقيقة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}+ मिनट लगते हैं",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}分以上",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}+ минут",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"select_option": "Variantni tanla",
|
||||
"select_options": "Variantlarni tanla",
|
||||
"sending_responses": "Javoblar yuborilmoqda...",
|
||||
"survey_dialog": "So‘rovnoma 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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user