diff --git a/packages/surveys/src/components/general/input.tsx b/packages/surveys/src/components/general/input.tsx index 6f75eec4f4..a7c408ad7b 100644 --- a/packages/surveys/src/components/general/input.tsx +++ b/packages/surveys/src/components/general/input.tsx @@ -7,13 +7,13 @@ export interface InputProps extends InputHTMLAttributes { export const Input = forwardRef(({ className, ...props }, ref) => { return ( ); }); diff --git a/packages/surveys/src/components/general/language-switch.test.tsx b/packages/surveys/src/components/general/language-switch.test.tsx index 16fd8b10f8..c6b95a17e0 100644 --- a/packages/surveys/src/components/general/language-switch.test.tsx +++ b/packages/surveys/src/components/general/language-switch.test.tsx @@ -1,6 +1,7 @@ import "@testing-library/jest-dom/vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/preact"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TSurveyLanguage } from "@formbricks/types/surveys/types"; import { LanguageSwitch } from "./language-switch"; @@ -50,6 +51,20 @@ describe("LanguageSwitch", () => { enabled: false, }, ]; + const mockSurvey = { + id: "1", + name: "Test Survey", + description: "Test Description", + createdAt: new Date(), + updatedAt: new Date(), + welcomeCard: { + enabled: true, + headline: { + en: "Test Welcome Card", + }, + }, + questions: [], + } as unknown as TJsEnvironmentStateSurvey; beforeEach(() => { vi.clearAllMocks(); @@ -65,6 +80,7 @@ describe("LanguageSwitch", () => { surveyLanguages={surveyLanguages} setSelectedLanguageCode={mockSetSelectedLanguageCode} setFirstRender={mockSetFirstRender} + survey={mockSurvey} /> ); @@ -89,6 +105,7 @@ describe("LanguageSwitch", () => { surveyLanguages={surveyLanguages} setSelectedLanguageCode={mockSetSelectedLanguageCode} setFirstRender={mockSetFirstRender} + survey={mockSurvey} /> ); diff --git a/packages/surveys/src/components/general/language-switch.tsx b/packages/surveys/src/components/general/language-switch.tsx index 5736c5d5b1..b1fce74c0b 100644 --- a/packages/surveys/src/components/general/language-switch.tsx +++ b/packages/surveys/src/components/general/language-switch.tsx @@ -1,24 +1,32 @@ import { LanguageIcon } from "@/components/icons/language-icon"; import { mixColor } from "@/lib/color"; import { useClickOutside } from "@/lib/use-click-outside-hook"; -import { cn } from "@/lib/utils"; +import { checkIfSurveyIsRTL, cn } from "@/lib/utils"; import { useRef, useState } from "preact/hooks"; import { getLanguageLabel } from "@formbricks/i18n-utils/src"; +import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { type TSurveyLanguage } from "@formbricks/types/surveys/types"; interface LanguageSwitchProps { + survey: TJsEnvironmentStateSurvey; surveyLanguages: TSurveyLanguage[]; setSelectedLanguageCode: (languageCode: string) => void; setFirstRender?: (firstRender: boolean) => void; hoverColor?: string; borderRadius?: number; + dir?: "ltr" | "rtl" | "auto"; + setDir?: (dir: "ltr" | "rtl" | "auto") => void; } + export function LanguageSwitch({ + survey, surveyLanguages, setSelectedLanguageCode, setFirstRender, hoverColor, borderRadius, + dir = "auto", + setDir, }: LanguageSwitchProps) { const hoverColorWithOpacity = hoverColor ?? mixColor("#000000", "#ffffff", 0.8); @@ -34,11 +42,14 @@ export function LanguageSwitch({ })?.language.code; const changeLanguage = (languageCode: string) => { - if (languageCode === defaultLanguageCode) { - setSelectedLanguageCode("default"); - } else { - setSelectedLanguageCode(languageCode); + const calculatedLanguageCode = languageCode === defaultLanguageCode ? "default" : languageCode; + setSelectedLanguageCode(calculatedLanguageCode); + + if (setDir) { + const calculateDir = checkIfSurveyIsRTL(survey, calculatedLanguageCode) ? "rtl" : "auto"; + setDir?.(calculateDir); } + if (setFirstRender) { //for lexical editor setFirstRender(true); @@ -74,7 +85,10 @@ export function LanguageSwitch({ {showLanguageDropdown ? (
{surveyLanguages.map((surveyLanguage) => { if (!surveyLanguage.enabled) return; diff --git a/packages/surveys/src/components/general/question-conditional.tsx b/packages/surveys/src/components/general/question-conditional.tsx index b0d774a5ea..edf002f8ee 100644 --- a/packages/surveys/src/components/general/question-conditional.tsx +++ b/packages/surveys/src/components/general/question-conditional.tsx @@ -44,6 +44,7 @@ interface QuestionConditionalProps { currentQuestionId: TSurveyQuestionId; isBackButtonHidden: boolean; onOpenExternalURL?: (url: string) => void | Promise; + dir?: "ltr" | "rtl" | "auto"; } export function QuestionConditional({ @@ -65,6 +66,7 @@ export function QuestionConditional({ currentQuestionId, isBackButtonHidden, onOpenExternalURL, + dir, }: QuestionConditionalProps) { const getResponseValueForRankingQuestion = ( value: string[], @@ -102,6 +104,7 @@ export function QuestionConditional({ autoFocusEnabled={autoFocusEnabled} currentQuestionId={currentQuestionId} isBackButtonHidden={isBackButtonHidden} + dir={dir} /> ) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? ( ) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? ( ) : question.type === TSurveyQuestionTypeEnum.NPS ? ( ) : question.type === TSurveyQuestionTypeEnum.CTA ? ( ) : question.type === TSurveyQuestionTypeEnum.Consent ? ( ) : question.type === TSurveyQuestionTypeEnum.Date ? ( ) : question.type === TSurveyQuestionTypeEnum.FileUpload ? ( ) : question.type === TSurveyQuestionTypeEnum.Ranking ? ( ) : null; } diff --git a/packages/surveys/src/components/general/render-survey.test.tsx b/packages/surveys/src/components/general/render-survey.test.tsx index ae7a195cd0..9faf216db8 100644 --- a/packages/surveys/src/components/general/render-survey.test.tsx +++ b/packages/surveys/src/components/general/render-survey.test.tsx @@ -34,7 +34,10 @@ describe("RenderSurvey", () => { test("renders with default props and handles close", () => { const onClose = vi.fn(); const onFinished = vi.fn(); - const survey = { endings: [{ id: "e1", type: "question" }] } as any; + const survey = { + endings: [{ id: "e1", type: "question" }], + welcomeCard: { enabled: true, headline: { en: "Welcome" } }, + } as any; render( ( @@ -66,7 +69,10 @@ describe("RenderSurvey", () => { test("onFinished skips close if redirectToUrl", () => { const onClose = vi.fn(); const onFinished = vi.fn(); - const survey = { endings: [{ id: "e1", type: "redirectToUrl" }] } as any; + const survey = { + endings: [{ id: "e1", type: "redirectToUrl" }], + welcomeCard: { enabled: true, headline: { en: "Welcome" } }, + } as any; render( ( @@ -91,7 +97,10 @@ describe("RenderSurvey", () => { test("onFinished closes after delay for non-redirect endings", () => { const onClose = vi.fn(); const onFinished = vi.fn(); - const survey = { endings: [{ id: "e1", type: "question" }] } as any; + const survey = { + endings: [{ id: "e1", type: "question" }], + welcomeCard: { enabled: true, headline: { en: "Welcome" } }, + } as any; render( ( @@ -118,7 +127,10 @@ describe("RenderSurvey", () => { test("onFinished does not auto-close when inline mode", () => { const onClose = vi.fn(); const onFinished = vi.fn(); - const survey = { endings: [] } as any; + const survey = { + endings: [], + welcomeCard: { enabled: true, headline: { en: "Welcome" } }, + } as any; render( ( @@ -143,7 +155,10 @@ describe("RenderSurvey", () => { test("close clears any pending onFinished timeout", () => { const onClose = vi.fn(); const onFinished = vi.fn(); - const survey = { endings: [{ id: "e1", type: "question" }] } as any; + const survey = { + endings: [{ id: "e1", type: "question" }], + welcomeCard: { enabled: true, headline: { en: "Welcome" } }, + } as any; const { unmount } = render( ( { test("double close only schedules one onClose", () => { const onClose = vi.fn(); const onFinished = vi.fn(); - const survey = { endings: [{ id: "e1", type: "question" }] } as any; + const survey = { + endings: [{ id: "e1", type: "question" }], + welcomeCard: { enabled: true, headline: { en: "Welcome" } }, + } as any; render( ( @@ -212,7 +230,11 @@ describe("RenderSurvey", () => { test("cleanup on unmount clears pending timers (useEffect)", () => { const onClose = vi.fn(); const onFinished = vi.fn(); - const survey = { endings: [{ id: "e1", type: "question" }] } as any; + const survey = { + endings: [{ id: "e1", type: "question" }], + welcomeCard: { enabled: true, headline: { en: "Welcome" } }, + } as any; + const { unmount } = render( ( (null); const closeTimeoutRef = useRef(null); + const isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode); + const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto"); + + useEffect(() => { + const isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode); + setDir(isRTL ? "rtl" : "auto"); + }, [props.languageCode, props.survey]); + const close = () => { if (onFinishedTimeoutRef.current) { clearTimeout(onFinishedTimeoutRef.current); @@ -51,7 +60,8 @@ export function RenderSurvey(props: SurveyContainerProps) { darkOverlay={props.darkOverlay} clickOutside={props.clickOutside} onClose={close} - isOpen={isOpen}> + isOpen={isOpen} + dir={dir}> {/* @ts-expect-error -- TODO: fix this */} ); diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index a73d907af8..32d57497bd 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -75,6 +75,8 @@ export function Survey({ isWebEnvironment = true, getRecaptchaToken, isSpamProtectionEnabled, + dir = "auto", + setDir, }: SurveyContainerProps) { let apiClient: ApiClient | null = null; @@ -172,7 +174,7 @@ export function Survey({ !getSetIsResponseSendingFinished ); const [isSurveyFinished, setIsSurveyFinished] = useState(false); - const [selectedLanguage, setselectedLanguage] = useState(languageCode); + const [selectedLanguage, setSelectedLanguage] = useState(languageCode); const [loadingElement, setLoadingElement] = useState(false); const [history, setHistory] = useState([]); const [responseData, setResponseData] = useState(hiddenFieldsRecord ?? {}); @@ -319,7 +321,7 @@ export function Survey({ }, [getSetIsResponseSendingFinished]); useEffect(() => { - setselectedLanguage(languageCode); + setSelectedLanguage(languageCode); }, [languageCode]); const onChange = (responseDataUpdate: TResponseData) => { @@ -730,6 +732,7 @@ export function Survey({ currentQuestionId={questionId} isBackButtonHidden={localSurvey.isBackButtonHidden} onOpenExternalURL={onOpenExternalURL} + dir={dir} /> ) ); @@ -760,13 +763,16 @@ export function Survey({ "fb-relative fb-w-full", isCloseButtonVisible || isLanguageSwitchVisible ? "fb-h-8" : "fb-h-5" )}> -
+
{isLanguageSwitchVisible && ( )} {isLanguageSwitchVisible && isCloseButtonVisible && ( diff --git a/packages/surveys/src/components/questions/address-question.tsx b/packages/surveys/src/components/questions/address-question.tsx index 56471ee54c..d8191d22cd 100644 --- a/packages/surveys/src/components/questions/address-question.tsx +++ b/packages/surveys/src/components/questions/address-question.tsx @@ -27,6 +27,7 @@ interface AddressQuestionProps { currentQuestionId: TSurveyQuestionId; autoFocusEnabled: boolean; isBackButtonHidden: boolean; + dir?: "ltr" | "rtl" | "auto"; } export function AddressQuestion({ @@ -43,6 +44,7 @@ export function AddressQuestion({ currentQuestionId, autoFocusEnabled, isBackButtonHidden, + dir = "auto", }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -170,6 +172,7 @@ export function AddressQuestion({ ref={index === 0 ? addressRef : null} tabIndex={isCurrent ? 0 : -1} aria-label={field.label} + dir={!safeValue[index] ? dir : "auto"} />
) diff --git a/packages/surveys/src/components/questions/consent-question.tsx b/packages/surveys/src/components/questions/consent-question.tsx index bbcba68e8d..250a5e2612 100644 --- a/packages/surveys/src/components/questions/consent-question.tsx +++ b/packages/surveys/src/components/questions/consent-question.tsx @@ -24,6 +24,7 @@ interface ConsentQuestionProps { autoFocusEnabled: boolean; currentQuestionId: TSurveyQuestionId; isBackButtonHidden: boolean; + dir?: "ltr" | "rtl" | "auto"; } export function ConsentQuestion({ @@ -40,6 +41,7 @@ export function ConsentQuestion({ currentQuestionId, autoFocusEnabled, isBackButtonHidden, + dir = "auto", }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -76,7 +78,6 @@ export function ConsentQuestion({ diff --git a/packages/surveys/src/components/questions/contact-info-question.tsx b/packages/surveys/src/components/questions/contact-info-question.tsx index 8d93ddb226..9a519e1b4e 100644 --- a/packages/surveys/src/components/questions/contact-info-question.tsx +++ b/packages/surveys/src/components/questions/contact-info-question.tsx @@ -27,6 +27,7 @@ interface ContactInfoQuestionProps { currentQuestionId: TSurveyQuestionId; autoFocusEnabled: boolean; isBackButtonHidden: boolean; + dir?: "ltr" | "rtl" | "auto"; } export function ContactInfoQuestion({ @@ -43,6 +44,7 @@ export function ContactInfoQuestion({ currentQuestionId, autoFocusEnabled, isBackButtonHidden, + dir = "auto", }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -169,6 +171,7 @@ export function ContactInfoQuestion({ }} tabIndex={isCurrent ? 0 : -1} aria-label={field.label} + dir={!safeValue[index] ? dir : "auto"} />
) diff --git a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx index 71760c6ddb..add9877052 100644 --- a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx @@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader"; import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; -import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils"; +import { cn, getShuffledChoicesIds } from "@/lib/utils"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; @@ -25,6 +25,7 @@ interface MultipleChoiceMultiProps { autoFocusEnabled: boolean; currentQuestionId: TSurveyQuestionId; isBackButtonHidden: boolean; + dir?: "ltr" | "rtl" | "auto"; } export function MultipleChoiceMultiQuestion({ @@ -41,6 +42,7 @@ export function MultipleChoiceMultiQuestion({ autoFocusEnabled, currentQuestionId, isBackButtonHidden, + dir = "auto", }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -140,11 +142,7 @@ export function MultipleChoiceMultiQuestion({ : question.required; }; - const otherOptionDir = useMemo(() => { - const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode); - if (!otherValue) return isRTL(placeholder) ? "rtl" : "ltr"; - return "auto"; - }, [languageCode, question.otherOptionPlaceholder, otherValue]); + const otherOptionInputDir = !otherValue ? dir : "auto"; return ( @@ -197,9 +195,10 @@ export function MultipleChoiceMultiQuestion({ } }} autoFocus={idx === 0 && autoFocusEnabled}> - + - + {getLocalizedValue(choice.label, languageCode)} @@ -241,9 +240,10 @@ export function MultipleChoiceMultiQuestion({ document.getElementById(otherOption.id)?.focus(); } }}> - + - + {getLocalizedValue(otherOption.label, languageCode)} {otherSelected ? ( ) { const [startTime, setStartTime] = useState(performance.now()); const [otherSelected, setOtherSelected] = useState(false); @@ -100,11 +102,7 @@ export function MultipleChoiceSingleQuestion({ } }, [otherSelected]); - const otherOptionDir = useMemo(() => { - const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode); - if (!value) return isRTL(placeholder) ? "rtl" : "ltr"; - return "auto"; - }, [languageCode, question.otherOptionPlaceholder, value]); + const otherOptionInputDir = !value ? dir : "auto"; return ( @@ -139,7 +137,6 @@ export function MultipleChoiceSingleQuestion({ if (!choice || choice.id === "other") return; return (