fix: rtl tweaks (#7136)

This commit is contained in:
Dhruwang Jariwala
2026-01-21 12:38:22 +05:30
committed by GitHub
parent 8f6d27c1ef
commit 8d47ab9709
19 changed files with 407 additions and 141 deletions
@@ -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} />
@@ -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}
+158 -55
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");
});
});
+55
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.
@@ -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