Compare commits

...

1 Commits

Author SHA1 Message Date
Dhruwang Jariwala
8d47ab9709 fix: rtl tweaks (#7136) 2026-01-21 07:08:22 +00:00
19 changed files with 407 additions and 141 deletions

View File

@@ -24,6 +24,7 @@ interface LinkSurveyWrapperProps {
IS_FORMBRICKS_CLOUD: boolean;
publicDomain: string;
isBrandingEnabled: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export const LinkSurveyWrapper = ({
@@ -41,6 +42,7 @@ export const LinkSurveyWrapper = ({
IS_FORMBRICKS_CLOUD,
publicDomain,
isBrandingEnabled,
dir = "auto",
}: LinkSurveyWrapperProps) => {
//for embedded survey strip away all surrounding css
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
@@ -80,11 +82,11 @@ export const LinkSurveyWrapper = ({
onBackgroundLoaded={handleBackgroundLoaded}>
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} dir={dir} />
)}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />
Survey Preview 👀
<ResetProgressButton onClick={handleResetSurvey} />

View File

@@ -10,6 +10,7 @@ import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { isRTLLanguage } from "@/modules/survey/link/lib/utils";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {
@@ -116,6 +117,11 @@ export const SurveyClientWrapper = ({
}
setResponseData({});
};
// Determine text direction based on language code for logo positioning only
// which checks both language code and survey content. This is only for logo UI positioning.
const logoDir = useMemo(() => {
return isRTLLanguage(survey, languageCode) ? "rtl" : "auto";
}, [languageCode, survey]);
return (
<>
@@ -140,7 +146,8 @@ export const SurveyClientWrapper = ({
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
isBrandingEnabled={project.linkSurveyBranding}>
isBrandingEnabled={project.linkSurveyBranding}
dir={logoDir}>
<SurveyInline
appUrl={publicDomain}
environmentId={survey.environmentId}

View File

@@ -1,85 +1,188 @@
import { describe, expect, test } from "vitest";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getWebAppLocale } from "./utils";
import { getElementsFromSurveyBlocks, getWebAppLocale, isRTL, isRTLLanguage } from "./utils";
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
({
id: "survey-1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test",
type: "link",
environmentId: "env-1",
createdBy: null,
status: "draft",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: {
enabled: false,
headline: { default: "Welcome" },
timeToFinish: false,
showResponseCount: false,
},
questions: [],
blocks: [],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
styling: null,
segment: null,
languages,
displayPercentage: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
singleUse: null,
pin: null,
projectOverwrites: null,
surveyClosedMessage: null,
followUps: [],
delay: 0,
autoComplete: null,
showLanguageSwitch: null,
recaptcha: null,
isBackButtonHidden: false,
isCaptureIpEnabled: false,
slug: null,
metadata: {},
}) as TSurvey;
describe("getWebAppLocale", () => {
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey => {
return {
id: "survey-1",
test("maps language codes and handles defaults", () => {
expect(getWebAppLocale("en", createMockSurvey())).toBe("en-US");
expect(getWebAppLocale("de", createMockSurvey())).toBe("de-DE");
const surveyWithLang = createMockSurvey([
{
language: {
id: "l1",
code: "de",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
]);
expect(getWebAppLocale("default", surveyWithLang)).toBe("de-DE");
expect(getWebAppLocale("xx", createMockSurvey())).toBe("en-US");
});
test("returns en-US when default requested but no default language", () => {
const surveyNoDefault = createMockSurvey([
{
language: { id: "l1", code: "de", alias: null, createdAt: new Date(), updatedAt: new Date(), projectId: "p1" },
default: false,
enabled: true,
},
]);
expect(getWebAppLocale("default", surveyNoDefault)).toBe("en-US");
});
test("matches base language code for variants", () => {
expect(getWebAppLocale("pt-PT", createMockSurvey())).toBe("pt-PT");
expect(getWebAppLocale("es-MX", createMockSurvey())).toBe("es-ES");
});
});
describe("isRTL", () => {
test("detects RTL characters", () => {
expect(isRTL("مرحبا")).toBe(true);
expect(isRTL("שלום")).toBe(true);
expect(isRTL("Hello")).toBe(false);
});
});
describe("isRTLLanguage", () => {
const createJsSurvey = (
languages: TJsEnvironmentStateSurvey["languages"] = [],
blocks: TSurveyBlock[] = []
): TJsEnvironmentStateSurvey =>
({
id: "s1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
name: "Test",
type: "link",
environmentId: "env-1",
createdBy: null,
status: "draft",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: {
enabled: false,
headline: { default: "Welcome" },
timeToFinish: false,
showResponseCount: false,
},
questions: [],
blocks: [],
endings: [],
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
styling: null,
segment: null,
blocks,
languages,
displayPercentage: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
singleUse: null,
pin: null,
projectOverwrites: null,
surveyClosedMessage: null,
followUps: [],
delay: 0,
autoComplete: null,
showLanguageSwitch: null,
recaptcha: null,
isBackButtonHidden: false,
isCaptureIpEnabled: false,
slug: null,
metadata: {},
} as TSurvey;
};
}) as unknown as TJsEnvironmentStateSurvey;
test("maps language codes to web app locales", () => {
const survey = createMockSurvey();
expect(getWebAppLocale("en", survey)).toBe("en-US");
expect(getWebAppLocale("de", survey)).toBe("de-DE");
expect(getWebAppLocale("pt-BR", survey)).toBe("pt-BR");
});
test("handles 'default' languageCode by finding default language in survey", () => {
const survey = createMockSurvey([
test("checks language codes when multi-language enabled", () => {
const survey = createJsSurvey([
{
language: {
id: "lang1",
code: "de",
id: "l1",
code: "ar",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "proj1",
projectId: "p1",
},
default: true,
enabled: true,
},
]);
expect(getWebAppLocale("default", survey)).toBe("de-DE");
expect(isRTLLanguage(survey, "ar")).toBe(true);
expect(isRTLLanguage(survey, "en")).toBe(false);
});
test("falls back to en-US when language is not supported", () => {
const survey = createMockSurvey();
expect(getWebAppLocale("default", survey)).toBe("en-US");
expect(getWebAppLocale("xx", survey)).toBe("en-US");
test("checks content when no languages configured", () => {
const element = {
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "مرحبا" },
required: false,
} as unknown as TSurveyElement;
const block = { id: "b1", name: "Block", elements: [element] } as TSurveyBlock;
expect(isRTLLanguage(createJsSurvey([], [block]), "default")).toBe(true);
});
test("checks welcomeCard headline when enabled and no languages", () => {
const survey = {
...createJsSurvey([], []),
welcomeCard: { enabled: true, headline: { default: "مرحبا" } },
} as unknown as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "default")).toBe(true);
});
test("returns false when no languages and no headlines found", () => {
const element = { id: "q1", type: TSurveyElementTypeEnum.OpenText, headline: {}, required: false };
const block = { id: "b1", name: "Block", elements: [element] } as TSurveyBlock;
expect(isRTLLanguage(createJsSurvey([], [block]), "default")).toBe(false);
});
});
describe("getElementsFromSurveyBlocks", () => {
test("extracts elements from blocks", () => {
const el1 = {
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
} as unknown as TSurveyElement;
const el2 = {
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: false,
} as unknown as TSurveyElement;
const block = { id: "b1", name: "Block", elements: [el1, el2] } as TSurveyBlock;
const result = getElementsFromSurveyBlocks([block]);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("q1");
});
});

View File

@@ -1,5 +1,60 @@
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
export function isRTL(text: string): boolean {
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlCharRegex.test(text);
}
/**
* List of RTL language codes
*/
const RTL_LANGUAGES = ["ar", "ar-SA", "ar-EG", "ar-AE", "ar-MA", "he", "fa", "ur"];
/**
* Returns true if the language code represents an RTL language.
* @param survey The survey to test
* @param languageCode The language code to test (e.g., "ar", "ar-SA", "he")
*/
export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: string): boolean {
if (survey.languages.length === 0) {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
}
}
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
}
}
return false;
} else {
const code =
languageCode === "default"
? survey.languages.find((language) => language.default)?.language.code
: languageCode;
const baseCode = code?.split("-")[0].toLowerCase() ?? "en";
return RTL_LANGUAGES.some((rtl) => rtl.toLowerCase().startsWith(baseCode));
}
}
/**
* Derives a flat array of elements from the survey's blocks structure.
* @param blocks The blocks array
* @returns An array of TSurveyElement (pure elements without block-level properties)
*/
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
/**
* Maps survey language codes to web app locale codes.
* Falls back to "en-US" if the language is not available in web app locales.

View File

@@ -13,6 +13,7 @@ interface ClientLogoProps {
projectLogo: Project["logo"] | null;
surveyLogo?: TLogo | null;
previewSurvey?: boolean;
dir?: "ltr" | "rtl" | "auto";
}
export const ClientLogo = ({
@@ -20,13 +21,23 @@ export const ClientLogo = ({
projectLogo,
surveyLogo,
previewSurvey = false,
dir = "auto",
}: ClientLogoProps) => {
const { t } = useTranslation();
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
let positionClasses = "";
if (!previewSurvey) {
if (dir === "rtl") {
positionClasses = "top-3 right-3 md:top-7 md:right-7";
} else {
positionClasses = "top-3 left-3 md:top-7 md:left-7";
}
}
return (
<div
className={cn(previewSurvey ? "" : "top-3 left-3 md:top-7 md:left-7", "group absolute z-0 rounded-lg")}
className={cn(positionClasses, "group absolute z-0 rounded-lg")}
style={{ backgroundColor: logoToUse?.bgColor }}>
{previewSurvey && environmentId && (
<Link

View File

@@ -304,7 +304,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3", optionLabelClassName)}
className={cn("mx-3", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -336,7 +336,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
className={cn("mx-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel}
</span>
@@ -385,7 +385,7 @@ function ListVariant({
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3", optionLabelClassName)}
className={cn("mx-3", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>

View File

@@ -60,26 +60,6 @@ interface RankingItemProps {
dir?: TextDirection;
}
function getTopButtonRadiusClass(isFirst: boolean, dir?: TextDirection): string {
if (isFirst) {
return "cursor-not-allowed opacity-30";
}
if (dir === "rtl") {
return "rounded-tl-md";
}
return "rounded-tr-md";
}
function getBottomButtonRadiusClass(isLast: boolean, dir?: TextDirection): string {
if (isLast) {
return "cursor-not-allowed opacity-30";
}
if (dir === "rtl") {
return "rounded-bl-md";
}
return "rounded-br-md";
}
function RankingItem({
item,
rankedIds,
@@ -94,21 +74,11 @@ function RankingItem({
const isLast = isRanked && rankIndex === rankedIds.length - 1;
const displayNumber = isRanked ? rankIndex + 1 : undefined;
// RTL-aware padding class
const paddingClass = dir === "rtl" ? "pr-3" : "pl-3";
// RTL-aware border class for control buttons
const borderClass = dir === "rtl" ? "border-r" : "border-l";
// RTL-aware border radius classes for control buttons
const topButtonRadiusClass = getTopButtonRadiusClass(isFirst, dir);
const bottomButtonRadiusClass = getBottomButtonRadiusClass(isLast, dir);
return (
<div
dir={dir}
className={cn(
"rounded-option flex h-12 cursor-pointer items-center border transition-all",
paddingClass,
"rounded-option flex h-12 cursor-pointer items-center border px-3 transition-all",
"bg-option-bg border-option-border",
"hover:bg-option-hover-bg focus-within:border-brand focus-within:bg-option-selected-bg focus-within:shadow-sm",
isRanked && "bg-option-selected-bg border-brand",
@@ -138,16 +108,14 @@ function RankingItem({
)}>
{displayNumber}
</span>
<span
className="font-option text-option font-option-weight text-option-label shrink grow text-start"
dir={dir}>
<span className="font-option text-option font-option-weight text-option-label shrink grow text-start">
{item.label}
</span>
</button>
{/* Up/Down buttons for ranked items */}
{isRanked ? (
<div className={cn("border-option-border flex h-full grow-0 flex-col", borderClass)}>
<div className={cn("border-option-border -mx-3 flex h-full grow-0 flex-col")} dir={dir}>
<button
type="button"
tabIndex={isFirst ? -1 : 0}
@@ -157,10 +125,7 @@ function RankingItem({
}}
disabled={isFirst || disabled}
aria-label={`Move ${item.label} up`}
className={cn(
"flex flex-1 items-center justify-center px-2 transition-colors",
topButtonRadiusClass
)}>
className={cn("flex flex-1 items-center justify-center px-2 transition-colors")}>
<ChevronUp className="h-5 w-5" />
</button>
<button
@@ -173,8 +138,7 @@ function RankingItem({
disabled={isLast || disabled}
aria-label={`Move ${item.label} down`}
className={cn(
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors",
bottomButtonRadiusClass
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors"
)}>
<ChevronDown className="h-5 w-5" />
</button>
@@ -261,7 +225,6 @@ function Ranking({
{/* Ranking Options */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Ranking options</legend>
<div className="space-y-2" ref={parent as React.Ref<HTMLDivElement>}>

View File

@@ -244,6 +244,7 @@ function SingleSelect({
return (
<label
key={option.id}
dir={dir}
htmlFor={optionId}
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
<span className="flex items-center">
@@ -254,7 +255,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
className={cn("mx-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
@@ -265,6 +266,7 @@ function SingleSelect({
{hasOtherOption && otherOptionId ? (
<label
htmlFor={`${inputId}-${otherOptionId}`}
dir={dir}
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
@@ -304,6 +306,7 @@ function SingleSelect({
<label
key={option.id}
htmlFor={optionId}
dir={dir}
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
@@ -313,7 +316,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
className={cn("mx-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { DateElement as SurveyUIDateElement } from "@formbricks/survey-ui";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyDateElement } from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
@@ -16,6 +17,8 @@ interface DateElementProps {
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentElementId: string;
surveyLanguages: TSurveyLanguage[];
dir?: "ltr" | "rtl" | "auto";
}
export function DateElement({
@@ -26,6 +29,8 @@ export function DateElement({
ttc,
setTtc,
currentElementId,
surveyLanguages,
dir = "auto",
}: Readonly<DateElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
@@ -78,7 +83,12 @@ export function DateElement({
required={element.required}
requiredLabel={t("common.required")}
errorMessage={errorMessage}
locale={languageCode}
locale={
languageCode === "default"
? surveyLanguages.find((language) => language.default)?.language.code
: languageCode
}
dir={dir}
imageUrl={element.imageUrl}
videoUrl={element.videoUrl}
/>

View File

@@ -15,6 +15,7 @@ interface MatrixElementProps {
ttc: TResponseTtc;
setTtc: (ttc: TResponseTtc) => void;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
}
export function MatrixElement({
@@ -25,6 +26,7 @@ export function MatrixElement({
ttc,
setTtc,
currentElementId,
dir = "auto",
}: Readonly<MatrixElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
@@ -142,6 +144,7 @@ export function MatrixElement({
return (
<form key={element.id} onSubmit={handleSubmit} className="w-full">
<Matrix
dir={dir}
elementId={element.id}
inputId={element.id}
headline={getLocalizedValue(element.headline, languageCode)}

View File

@@ -16,6 +16,7 @@ interface RankingElementProps {
setTtc: (ttc: TResponseTtc) => void;
autoFocusEnabled: boolean;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
}
export function RankingElement({
@@ -26,6 +27,7 @@ export function RankingElement({
ttc,
setTtc,
currentElementId,
dir = "auto",
}: Readonly<RankingElementProps>) {
const { t } = useTranslation();
const [startTime, setStartTime] = useState(performance.now());
@@ -128,6 +130,7 @@ export function RankingElement({
return (
<form onSubmit={handleSubmit} className="w-full">
<Ranking
dir={dir}
elementId={element.id}
inputId={element.id}
headline={getLocalizedValue(element.headline, languageCode)}

View File

@@ -9,6 +9,7 @@ import {
type TSurveyMatrixElement,
type TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { ElementConditional } from "@/components/general/element-conditional";
@@ -36,6 +37,7 @@ interface BlockConditionalProps {
onOpenExternalURL?: (url: string) => void | Promise<void>;
dir?: "ltr" | "rtl" | "auto";
fullSizeCards: boolean;
surveyLanguages: TSurveyLanguage[];
}
export function BlockConditional({
@@ -58,7 +60,8 @@ export function BlockConditional({
onOpenExternalURL,
dir,
fullSizeCards,
}: BlockConditionalProps) {
surveyLanguages,
}: Readonly<BlockConditionalProps>) {
// Track the current element being filled (for TTC tracking)
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
@@ -290,6 +293,7 @@ export function BlockConditional({
return (
<div key={element.id}>
<ElementConditional
surveyLanguages={surveyLanguages}
element={element}
value={value[element.id]}
onChange={(responseData) => handleElementChange(element.id, responseData)}

View File

@@ -4,6 +4,7 @@ import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "
import { type TUploadFileConfig } from "@formbricks/types/storage";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/types/surveys/elements";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { AddressElement } from "@/components/elements/address-element";
import { CalElement } from "@/components/elements/cal-element";
import { ConsentElement } from "@/components/elements/consent-element";
@@ -36,6 +37,7 @@ interface ElementConditionalProps {
dir?: "ltr" | "rtl" | "auto";
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
surveyLanguages: TSurveyLanguage[];
}
export function ElementConditional({
@@ -53,7 +55,8 @@ export function ElementConditional({
dir,
formRef,
onTtcCollect,
}: ElementConditionalProps) {
surveyLanguages,
}: Readonly<ElementConditionalProps>) {
// Ref to the container div, used to find and expose the form element inside
const containerRef = useRef<HTMLDivElement>(null);
@@ -224,6 +227,8 @@ export function ElementConditional({
setTtc={wrappedSetTtc}
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
surveyLanguages={surveyLanguages}
dir={dir}
/>
);
case TSurveyElementTypeEnum.PictureSelection:
@@ -280,6 +285,7 @@ export function ElementConditional({
ttc={ttc}
setTtc={wrappedSetTtc}
currentElementId={currentElementId}
dir={dir}
/>
);
case TSurveyElementTypeEnum.Address:
@@ -299,6 +305,7 @@ export function ElementConditional({
case TSurveyElementTypeEnum.Ranking:
return (
<RankingElement
dir={dir}
element={element}
value={Array.isArray(value) ? getResponseValueForRankingElement(value, element.choices) : []}
onChange={onChange}

View File

@@ -8,7 +8,7 @@ import { mixColor } from "@/lib/color";
import { getI18nLanguage } from "@/lib/i18n-utils";
import i18n from "@/lib/i18n.config";
import { useClickOutside } from "@/lib/use-click-outside-hook";
import { checkIfSurveyIsRTL, cn } from "@/lib/utils";
import { cn, isRTLLanguage } from "@/lib/utils";
interface LanguageSwitchProps {
survey: TJsEnvironmentStateSurvey;
@@ -59,7 +59,7 @@ export function LanguageSwitch({
handleI18nLanguage(calculatedLanguageCode);
if (setDir) {
const calculateDir = checkIfSurveyIsRTL(survey, calculatedLanguageCode) ? "rtl" : "auto";
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto";
setDir?.(calculateDir);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { checkIfSurveyIsRTL } from "@/lib/utils";
import { isRTLLanguage } from "@/lib/utils";
import { SurveyContainer } from "../wrappers/survey-container";
import { Survey } from "./survey";
@@ -8,12 +8,11 @@ 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 isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode);
const isRTL = isRTLLanguage(props.survey, props.languageCode);
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto");
useEffect(() => {
const isRTL = checkIfSurveyIsRTL(props.survey, props.languageCode);
const isRTL = isRTLLanguage(props.survey, props.languageCode);
setDir(isRTL ? "rtl" : "auto");
}, [props.languageCode, props.survey]);

View File

@@ -801,6 +801,7 @@ export function Survey({
return (
Boolean(block) && (
<BlockConditional
surveyLanguages={localSurvey.languages}
key={block.id}
surveyId={localSurvey.id}
block={{

View File

@@ -30,7 +30,7 @@ interface WelcomeCardProps {
function TimerIcon() {
return (
<div className="mr-1">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
@@ -177,18 +177,16 @@ export function WelcomeCard({
</div>
{timeToFinish && !showResponseCount ? (
<div
className="text-subheading my-4 flex items-center"
className="text-subheading my-4 flex items-center space-x-1"
data-testid="fb__surveys__welcome-card__time-display">
<TimerIcon />
<p className="pt-1 text-xs">
<span>
{calculateTimeToComplete()}{" "}
</span>
<span>{calculateTimeToComplete()} </span>
</p>
</div>
) : null}
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
<div className="text-subheading my-4 flex items-center">
<div className="text-subheading my-4 flex items-center space-x-1">
<UsersIcon />
<p className="pt-1 text-xs">
<span data-testid="fb__surveys__welcome-card__response-count">
@@ -198,12 +196,10 @@ export function WelcomeCard({
</div>
) : null}
{timeToFinish && showResponseCount ? (
<div className="text-subheading my-4 flex items-center">
<div className="text-subheading my-4 flex items-center space-x-1">
<TimerIcon />
<p className="pt-1 text-xs" data-testid="fb__surveys__welcome-card__info-text-test">
<span>
{calculateTimeToComplete()}{" "}
</span>
<span>{calculateTimeToComplete()} </span>
<span data-testid="fb__surveys__welcome-card__response-count">
{responseCount && responseCount > 3
? `${t("common.people_responded", { count: responseCount })}`

View File

@@ -10,6 +10,8 @@ import {
getMimeType,
getShuffledChoicesIds,
getShuffledRowIndices,
isRTL,
isRTLLanguage,
} from "./utils";
// Mock crypto.getRandomValues for deterministic shuffle tests
@@ -327,3 +329,83 @@ describe("findBlockByElementId", () => {
expect(block).toBeUndefined();
});
});
describe("isRTL", () => {
test("returns true for RTL text", () => {
expect(isRTL("مرحبا")).toBe(true);
expect(isRTL("שלום")).toBe(true);
});
test("returns false for LTR text", () => {
expect(isRTL("Hello")).toBe(false);
expect(isRTL("")).toBe(false);
});
});
describe("isRTLLanguage", () => {
test("returns true for RTL language codes when multi-language enabled", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [
{
language: {
id: "l1",
code: "ar",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "ar")).toBe(true);
expect(isRTLLanguage(survey, "he")).toBe(true);
});
test("returns false for LTR language codes", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
languages: [
{
language: {
id: "l1",
code: "en",
alias: null,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "p1",
},
default: true,
enabled: true,
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "en")).toBe(false);
});
test("checks survey content when no languages configured", () => {
const survey: TJsEnvironmentStateSurvey = {
...baseMockSurvey,
blocks: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "مرحبا" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
},
],
} as TJsEnvironmentStateSurvey;
expect(isRTLLanguage(survey, "default")).toBe(true);
});
});

View File

@@ -222,26 +222,43 @@ export function isRTL(text: string): boolean {
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);
/**
* List of RTL language codes
*/
const RTL_LANGUAGES = ["ar", "ar-SA", "ar-EG", "ar-AE", "ar-MA", "he", "fa", "ur"];
/**
* Returns true if the language code represents an RTL language.
* @param languageCode The language code to test (e.g., "ar", "ar-SA", "he")
*/
export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: string): boolean {
if (survey.languages.length === 0) {
if (survey.welcomeCard.enabled) {
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
if (welcomeCardHeadline) {
return isRTL(welcomeCardHeadline);
}
}
}
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
const questions = getElementsFromSurveyBlocks(survey.blocks);
for (const question of questions) {
const questionHeadline = question.headline[languageCode];
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
// the first non-empty question headline is the survey direction
if (questionHeadline) {
return isRTL(questionHeadline);
}
}
return false;
} else {
const code =
languageCode === "default"
? survey.languages.find((language) => language.default)?.language.code
: languageCode;
const baseCode = code?.split("-")[0].toLowerCase() ?? "en";
return RTL_LANGUAGES.some((rtl) => rtl.toLowerCase().startsWith(baseCode));
}
return false;
};
}
/**
* Derives a flat array of elements from the survey's blocks structure.