fix: surveys package rtl (#6379)

Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Anshuman Pandey
2025-08-27 11:22:46 +05:30
committed by GitHub
parent a8c8e6f83f
commit c39c9998f0
20 changed files with 225 additions and 73 deletions

View File

@@ -7,13 +7,13 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
return (
<input
{...props}
ref={ref} // Forward the ref to the input element
className={cn(
"focus:fb-border-brand fb-bg-input-bg fb-flex fb-w-full fb-border fb-border-border fb-rounded-custom fb-px-3 fb-py-2 fb-text-sm fb-text-subheading placeholder:fb-text-placeholder focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50 dark:fb-border-slate-500 dark:fb-text-slate-300",
className ?? ""
)}
dir="auto"
{...props}
/>
);
});

View File

@@ -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}
/>
);

View File

@@ -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({
</button>
{showLanguageDropdown ? (
<div
className="fb-bg-brand fb-text-on-brand fb-absolute fb-right-8 fb-top-10 fb-space-y-2 fb-rounded-md fb-p-2 fb-text-xs"
className={cn(
"fb-bg-brand fb-text-on-brand fb-absolute fb-top-10 fb-space-y-2 fb-rounded-md fb-p-2 fb-text-xs",
dir === "rtl" ? "fb-left-8" : "fb-right-8"
)}
ref={languageDropdownRef}>
{surveyLanguages.map((surveyLanguage) => {
if (!surveyLanguage.enabled) return;

View File

@@ -44,6 +44,7 @@ interface QuestionConditionalProps {
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
onOpenExternalURL?: (url: string) => void | Promise<void>;
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 ? (
<MultipleChoiceSingleQuestion
@@ -119,6 +122,7 @@ export function QuestionConditional({
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
isBackButtonHidden={isBackButtonHidden}
dir={dir}
/>
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? (
<MultipleChoiceMultiQuestion
@@ -136,6 +140,7 @@ export function QuestionConditional({
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
isBackButtonHidden={isBackButtonHidden}
dir={dir}
/>
) : question.type === TSurveyQuestionTypeEnum.NPS ? (
<NPSQuestion
@@ -153,6 +158,7 @@ export function QuestionConditional({
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
isBackButtonHidden={isBackButtonHidden}
dir={dir}
/>
) : question.type === TSurveyQuestionTypeEnum.CTA ? (
<CTAQuestion
@@ -188,6 +194,7 @@ export function QuestionConditional({
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
isBackButtonHidden={isBackButtonHidden}
dir={dir}
/>
) : question.type === TSurveyQuestionTypeEnum.Consent ? (
<ConsentQuestion
@@ -205,6 +212,7 @@ export function QuestionConditional({
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
isBackButtonHidden={isBackButtonHidden}
dir={dir}
/>
) : question.type === TSurveyQuestionTypeEnum.Date ? (
<DateQuestion
@@ -239,6 +247,7 @@ export function QuestionConditional({
autoFocusEnabled={autoFocusEnabled}
currentQuestionId={currentQuestionId}
isBackButtonHidden={isBackButtonHidden}
dir={dir}
/>
) : question.type === TSurveyQuestionTypeEnum.FileUpload ? (
<FileUploadQuestion
@@ -306,6 +315,7 @@ export function QuestionConditional({
currentQuestionId={currentQuestionId}
autoFocusEnabled={autoFocusEnabled}
isBackButtonHidden={isBackButtonHidden}
dir={dir}
/>
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
<RankingQuestion
@@ -338,6 +348,7 @@ export function QuestionConditional({
currentQuestionId={currentQuestionId}
autoFocusEnabled={autoFocusEnabled}
isBackButtonHidden={isBackButtonHidden}
dir={dir}
/>
) : null;
}

View File

@@ -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(
(
<RenderSurvey
@@ -178,7 +193,10 @@ describe("RenderSurvey", () => {
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(
(
<RenderSurvey

View File

@@ -1,3 +1,4 @@
import { checkIfSurveyIsRTL } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { SurveyContainer } from "../wrappers/survey-container";
@@ -8,6 +9,14 @@ export function RenderSurvey(props: SurveyContainerProps) {
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(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 */}
<Survey
{...props}
@@ -72,6 +82,8 @@ export function RenderSurvey(props: SurveyContainerProps) {
);
}
}}
dir={dir}
setDir={setDir}
/>
</SurveyContainer>
);

View File

@@ -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<string[]>([]);
const [responseData, setResponseData] = useState<TResponseData>(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"
)}>
<div className="fb-flex fb-items-center fb-justify-end fb-absolute fb-top-0 fb-right-0">
<div className={cn("fb-flex fb-items-center fb-justify-end fb-w-full")}>
{isLanguageSwitchVisible && (
<LanguageSwitch
survey={localSurvey}
surveyLanguages={localSurvey.languages}
setSelectedLanguageCode={setselectedLanguage}
setSelectedLanguageCode={setSelectedLanguage}
hoverColor={styling.inputColor?.light ?? "#f8fafc"}
borderRadius={styling.roundness ?? 8}
setDir={setDir}
dir={dir}
/>
)}
{isLanguageSwitchVisible && isCloseButtonVisible && (

View File

@@ -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<AddressQuestionProps>) {
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"}
/>
</div>
)

View File

@@ -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<ConsentQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -76,7 +78,6 @@ export function ConsentQuestion({
<HtmlBody htmlString={getLocalizedValue(question.html, languageCode) || ""} />
<label
ref={consentRef}
dir="auto"
tabIndex={isCurrent ? 0 : -1}
id={`${question.id}-label`}
onKeyDown={(e) => {
@@ -91,6 +92,7 @@ export function ConsentQuestion({
<input
tabIndex={-1}
type="checkbox"
dir={dir}
id={question.id}
name={question.id}
value={getLocalizedValue(question.label, languageCode)}
@@ -106,7 +108,7 @@ export function ConsentQuestion({
aria-labelledby={`${question.id}-label`}
required={question.required}
/>
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium">
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium fb-flex-1" dir="auto">
{getLocalizedValue(question.label, languageCode)}
</span>
</label>

View File

@@ -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<ContactInfoQuestionProps>) {
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"}
/>
</div>
)

View File

@@ -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<MultipleChoiceMultiProps>) {
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 (
<ScrollableContainer>
@@ -197,9 +195,10 @@ export function MultipleChoiceMultiQuestion({
}
}}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
id={choice.id}
name={question.id}
tabIndex={-1}
@@ -219,7 +218,7 @@ export function MultipleChoiceMultiQuestion({
}
required={getIsRequired()}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
<span id={`${choice.id}-label`} className="fb-mx-3 fb-grow fb-font-medium" dir="auto">
{getLocalizedValue(choice.label, languageCode)}
</span>
</span>
@@ -241,9 +240,10 @@ export function MultipleChoiceMultiQuestion({
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<span className="fb-flex fb-items-center fb-text-sm">
<input
type="checkbox"
dir={dir}
tabIndex={-1}
id={otherOption.id}
name={question.id}
@@ -263,14 +263,17 @@ export function MultipleChoiceMultiQuestion({
}}
checked={otherSelected}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
<span
id={`${otherOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
{otherSelected ? (
<input
ref={otherSpecify}
dir={otherOptionDir}
dir={otherOptionInputDir}
id={`${otherOption.id}-label`}
maxLength={250}
name={question.id}

View File

@@ -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 { 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 MultipleChoiceSingleProps {
autoFocusEnabled: boolean;
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export function MultipleChoiceSingleQuestion({
@@ -41,6 +42,7 @@ export function MultipleChoiceSingleQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
dir = "auto",
}: Readonly<MultipleChoiceSingleProps>) {
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 (
<ScrollableContainer>
@@ -139,7 +137,6 @@ export function MultipleChoiceSingleQuestion({
if (!choice || choice.id === "other") return;
return (
<label
dir="auto"
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={cn(
@@ -164,7 +161,7 @@ export function MultipleChoiceSingleQuestion({
id={choice.id}
name={question.id}
value={getLocalizedValue(choice.label, languageCode)}
dir="auto"
dir={dir}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={() => {
@@ -174,7 +171,10 @@ export function MultipleChoiceSingleQuestion({
checked={value === getLocalizedValue(choice.label, languageCode)}
required={question.required ? idx === 0 : undefined}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
<span
id={`${choice.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(choice.label, languageCode)}
</span>
</span>
@@ -183,7 +183,6 @@ export function MultipleChoiceSingleQuestion({
})}
{otherOption ? (
<label
dir="auto"
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(otherOption.label, languageCode)
@@ -199,10 +198,10 @@ export function MultipleChoiceSingleQuestion({
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
dir="auto"
dir={dir}
type="radio"
id={otherOption.id}
name={question.id}
@@ -215,7 +214,10 @@ export function MultipleChoiceSingleQuestion({
}}
checked={otherSelected}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
<span
id={`${otherOption.id}-label`}
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
dir="auto">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
@@ -223,7 +225,7 @@ export function MultipleChoiceSingleQuestion({
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
dir={otherOptionDir}
dir={otherOptionInputDir}
name={question.id}
pattern=".*\S+.*"
value={value}

View File

@@ -25,6 +25,7 @@ interface NPSQuestionProps {
autoFocusEnabled: boolean;
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export function NPSQuestion({
@@ -40,6 +41,7 @@ export function NPSQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
dir = "auto",
}: Readonly<NPSQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [hoveredNumber, setHoveredNumber] = useState(-1);
@@ -120,9 +122,12 @@ export function NPSQuestion({
value === number
? "fb-border-border-highlight fb-bg-accent-selected-bg fb-z-10 fb-border"
: "fb-border-border",
"fb-text-heading first:fb-rounded-l-custom last:fb-rounded-r-custom focus:fb-border-brand fb-relative fb-h-10 fb-flex-1 fb-cursor-pointer fb-overflow-hidden fb-border-b fb-border-l fb-border-t fb-text-center fb-text-sm last:fb-border-r focus:fb-border-2 focus:fb-outline-none",
"fb-text-heading focus:fb-border-brand fb-relative fb-h-10 fb-flex-1 fb-cursor-pointer fb-overflow-hidden fb-border-b fb-border-l fb-border-t fb-text-center fb-text-sm focus:fb-border-2 focus:fb-outline-none",
question.isColorCodingEnabled ? "fb-h-[46px] fb-leading-[3.5em]" : "fb-h fb-leading-10",
hoveredNumber === number ? "fb-bg-accent-bg" : ""
hoveredNumber === number ? "fb-bg-accent-bg" : "",
dir === "rtl"
? "first:fb-rounded-r-custom first:fb-border-r last:fb-rounded-l-custom last:fb-border-l"
: "first:fb-rounded-l-custom first:fb-border-l last:fb-rounded-r-custom last:fb-border-r"
)}>
{question.isColorCodingEnabled ? (
<div
@@ -147,9 +152,13 @@ export function NPSQuestion({
);
})}
</div>
<div className="fb-text-subheading fb-mt-2 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-space-x-8">
<p dir="auto">{getLocalizedValue(question.lowerLabel, languageCode)}</p>
<p dir="auto">{getLocalizedValue(question.upperLabel, languageCode)}</p>
<div className="fb-text-subheading fb-mt-2 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-gap-8">
<p dir="auto" className="fb-max-w-[50%]">
{getLocalizedValue(question.lowerLabel, languageCode)}
</p>
<p dir="auto" className="fb-max-w-[50%]">
{getLocalizedValue(question.upperLabel, languageCode)}
</p>
</div>
</fieldset>
</div>

View File

@@ -6,9 +6,8 @@ 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 { isRTL } from "@/lib/utils";
import { type RefObject } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -27,6 +26,7 @@ interface OpenTextQuestionProps {
autoFocusEnabled: boolean;
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export function OpenTextQuestion({
@@ -43,6 +43,7 @@ export function OpenTextQuestion({
autoFocusEnabled,
currentQuestionId,
isBackButtonHidden,
dir = "auto",
}: Readonly<OpenTextQuestionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [currentLength, setCurrentLength] = useState(value.length || 0);
@@ -81,11 +82,7 @@ export function OpenTextQuestion({
onSubmit({ [question.id]: value }, updatedTtc);
};
const dir = useMemo(() => {
const placeholder = getLocalizedValue(question.placeholder, languageCode);
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
return "auto";
}, [value, languageCode, question.placeholder]);
const computedDir = !value ? dir : "auto";
return (
<ScrollableContainer>
@@ -109,7 +106,7 @@ export function OpenTextQuestion({
name={question.id}
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir={dir}
dir={computedDir}
step="any"
required={question.required}
value={value ? value : ""}

View File

@@ -27,6 +27,7 @@ interface PictureSelectionProps {
autoFocusEnabled: boolean;
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export function PictureSelectionQuestion({
@@ -42,6 +43,7 @@ export function PictureSelectionQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
dir = "auto",
}: Readonly<PictureSelectionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(() => {
@@ -169,8 +171,9 @@ export function PictureSelectionQuestion({
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : "",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
)}
required={question.required && value.length === 0}
/>
@@ -182,8 +185,9 @@ export function PictureSelectionQuestion({
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
"fb-border-border fb-pointer-events-none fb-absolute fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : "",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
)}
required={question.required && value.length ? false : question.required}
/>
@@ -198,7 +202,10 @@ export function PictureSelectionQuestion({
onClick={(e) => {
e.stopPropagation();
}}
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
className={cn(
"fb-absolute fb-bottom-4 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20",
dir === "rtl" ? "fb-left-2" : "fb-right-2"
)}>
<span className="fb-sr-only">Open in new tab</span>
<ImageDownIcon />
</a>

View File

@@ -38,6 +38,7 @@ interface RatingQuestionProps {
autoFocusEnabled: boolean;
currentQuestionId: TSurveyQuestionId;
isBackButtonHidden: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export function RatingQuestion({
@@ -53,6 +54,7 @@ export function RatingQuestion({
setTtc,
currentQuestionId,
isBackButtonHidden,
dir = "auto",
}: RatingQuestionProps) {
const [hoveredNumber, setHoveredNumber] = useState(0);
const [startTime, setStartTime] = useState(performance.now());
@@ -160,8 +162,16 @@ export function RatingQuestion({
value === number
? "fb-bg-accent-selected-bg fb-border-border-highlight fb-z-10 fb-border"
: "fb-border-border",
a.length === number ? "fb-rounded-r-custom fb-border-r" : "",
number === 1 ? "fb-rounded-l-custom" : "",
a.length === number
? dir === "rtl"
? "fb-rounded-l-custom fb-border-l"
: "fb-rounded-r-custom fb-border-r"
: "",
number === 1
? dir === "rtl"
? "fb-rounded-r-custom fb-border-r"
: "fb-rounded-l-custom fb-border-l"
: "",
hoveredNumber === number ? "fb-bg-accent-bg" : "",
question.isColorCodingEnabled ? "fb-min-h-[47px]" : "fb-min-h-[41px]",
"fb-text-heading focus:fb-border-brand fb-relative fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-justify-center fb-overflow-hidden fb-border-b fb-border-l fb-border-t focus:fb-border-2 focus:fb-outline-none"
@@ -245,11 +255,11 @@ export function RatingQuestion({
</span>
))}
</div>
<div className="fb-text-subheading fb-mt-4 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-space-x-8">
<p className="fb-w-1/2 fb-text-left" dir="auto">
<div className="fb-text-subheading fb-mt-4 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-gap-8">
<p className="fb-max-w-[50%]" dir="auto">
{getLocalizedValue(question.lowerLabel, languageCode)}
</p>
<p className="fb-w-1/2 fb-text-right" dir="auto">
<p className="fb-max-w-[50%]" dir="auto">
{getLocalizedValue(question.upperLabel, languageCode)}
</p>
</div>

View File

@@ -10,6 +10,7 @@ interface SurveyContainerProps {
onClose?: () => void;
clickOutside?: boolean;
isOpen?: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export function SurveyContainer({
@@ -20,6 +21,7 @@ export function SurveyContainer({
onClose,
clickOutside,
isOpen = true,
dir = "auto",
}: Readonly<SurveyContainerProps>) {
const modalRef = useRef<HTMLDivElement>(null);
const isCenter = placement === "center";
@@ -67,14 +69,14 @@ export function SurveyContainer({
if (!isModal) {
return (
<div id="fbjs" className="fb-formbricks-form" style={{ height: "100%", width: "100%" }}>
<div id="fbjs" className="fb-formbricks-form" style={{ height: "100%", width: "100%" }} dir={dir}>
{children}
</div>
);
}
return (
<div id="fbjs" className="fb-formbricks-form">
<div id="fbjs" className="fb-formbricks-form" dir={dir}>
<div
aria-live="assertive"
className={cn(

View File

@@ -34,13 +34,23 @@ export const renderSurvey = (props: SurveyContainerProps) => {
const { placement, darkOverlay, onClose, clickOutside, ...surveyInlineProps } = props;
render(h(RenderSurvey, surveyInlineProps), element);
render(
h(RenderSurvey, {
...surveyInlineProps,
}),
element
);
} else {
const modalContainer = document.createElement("div");
modalContainer.id = "formbricks-modal-container";
document.body.appendChild(modalContainer);
render(h(RenderSurvey, props), modalContainer);
render(
h(RenderSurvey, {
...props,
}),
modalContainer
);
}
};

View File

@@ -177,3 +177,23 @@ export function isRTL(text: string): boolean {
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlCharRegex.test(text);
}
export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCode: string): boolean => {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
}
}
for (const question of survey.questions) {
const questionHeadline = question.headline[languageCode];
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
}
}
return false;
};

View File

@@ -22,6 +22,8 @@ export interface SurveyBaseProps {
prefillResponseData?: TResponseData;
skipPrefilled?: boolean;
languageCode: string;
dir?: "ltr" | "rtl" | "auto";
setDir?: (dir: "ltr" | "rtl" | "auto") => void;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
responseCount?: number;
isCardBorderVisible?: boolean;