mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-05 19:30:48 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28280899ea | |||
| bc63870289 |
@@ -155,3 +155,31 @@ jobs:
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
linear-release-complete:
|
||||
name: Mark Linear release as complete
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- docker-build-cloud
|
||||
- helm-chart-release
|
||||
- move-stable-tag
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Complete Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: complete
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Linear Release Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linear-release:
|
||||
name: Sync release to Linear
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getIsFreshInstance, gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const Page = async () => {
|
||||
const [session, isFreshInstance, hasNoOrganizations] = await Promise.all([
|
||||
getServerSession(authOptions),
|
||||
getIsFreshInstance(),
|
||||
gethasNoOrganizations(),
|
||||
]);
|
||||
|
||||
if (isFreshInstance) {
|
||||
return redirect("/setup/intro");
|
||||
}
|
||||
|
||||
if (hasNoOrganizations) {
|
||||
if (session) {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
|
||||
}
|
||||
|
||||
return redirect("/");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,14 +1,29 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getIsFreshInstance, gethasNoOrganizations } from "@/lib/instance/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const isFreshInstance = await getIsFreshInstance();
|
||||
|
||||
if (session || !isFreshInstance) {
|
||||
if (!isFreshInstance) {
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
|
||||
if (hasNoOrganizations) {
|
||||
if (session) {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
|
||||
}
|
||||
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,7 +27,6 @@
|
||||
"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,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
|
||||
import { isRTLLanguage } from "@/lib/utils";
|
||||
import { SurveyContainer } from "../wrappers/survey-container";
|
||||
@@ -8,7 +8,6 @@ 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");
|
||||
|
||||
@@ -18,7 +17,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 = useCallback(() => {
|
||||
const close = () => {
|
||||
if (onFinishedTimeoutRef.current) {
|
||||
clearTimeout(onFinishedTimeoutRef.current);
|
||||
onFinishedTimeoutRef.current = null;
|
||||
@@ -32,9 +31,11 @@ export function RenderSurvey(props: SurveyContainerProps) {
|
||||
setIsOpen(false);
|
||||
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
onClose?.();
|
||||
if (props.onClose) {
|
||||
props.onClose();
|
||||
}
|
||||
}, 1000);
|
||||
}, [onClose]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -63,6 +64,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
|
||||
onClose={close}
|
||||
isOpen={isOpen}
|
||||
dir={dir}>
|
||||
{/* @ts-expect-error -- TODO: fix this */}
|
||||
<Survey
|
||||
{...props}
|
||||
clickOutside={hasOverlay ? props.clickOutside : true}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// @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,15 +1,12 @@
|
||||
import { type ComponentChildren } from "preact";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef } 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: ComponentChildren;
|
||||
children: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
clickOutside?: boolean;
|
||||
isOpen?: boolean;
|
||||
@@ -26,9 +23,8 @@ 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(() => {
|
||||
@@ -51,7 +47,7 @@ export function SurveyContainer({
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [clickOutside, hasOverlay, modalRef, onClose, isModal, isOpen]);
|
||||
}, [clickOutside, onClose, isModal, isOpen]);
|
||||
|
||||
const getPlacementStyle = (placement: TPlacement): string => {
|
||||
switch (placement) {
|
||||
@@ -96,10 +92,6 @@ 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",
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
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