Compare commits

..

2 Commits

Author SHA1 Message Date
Johannes 28280899ea fix: recover incomplete initial setup (#7912) 2026-05-05 14:28:23 +00:00
Matti Nannt bc63870289 feat: add Linear Releases integration to CI pipeline (#7921)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:17:48 +00:00
28 changed files with 115 additions and 627 deletions
+28
View File
@@ -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 }}
+30
View File
@@ -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 }}
+28
View File
@@ -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}</>;
};
-1
View File
@@ -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}+ دقيقة",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -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}+ минут",
-1
View File
@@ -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",
-1
View File
@@ -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",
-1
View File
@@ -27,7 +27,6 @@
"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,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);
});
});
-272
View File
@@ -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;
};