Compare commits

...

4 Commits

Author SHA1 Message Date
Johannes
c6cbcd8280 fix unit test 2026-02-03 11:52:09 +01:00
Johannes
23ef11d228 Merge branch 'main' of https://github.com/formbricks/formbricks into fix-lang-attribute 2026-02-03 11:49:58 +01:00
Johannes
6c07e71b47 add translations 2026-01-27 14:59:39 +04:00
Johannes
242b003048 feat: add language attribute and improve attributes section rendering
- Introduced a new "Language" attribute in the environment service and database seeding.
- Refactored the attributes section to use a reusable AttributeRow component for better maintainability.
- Updated localization for attribute key terminology from "Key" to "Attribute".
- Enhanced delete logic in the edit contact attributes modal to prevent deletion of essential attributes.
2026-01-21 16:47:23 +00:00
29 changed files with 216 additions and 107 deletions

View File

@@ -579,7 +579,7 @@ checksums:
environments/contacts/attribute_created_successfully: e9f90d366d817f2f1c81fb819c0e2f05
environments/contacts/attribute_description: e17686a22ffad04cc7bb70524ed4478b
environments/contacts/attribute_description_placeholder: 05af83e4cfc6328476ef9719581e47af
environments/contacts/attribute_key: 3d1065ab98a1c2f1210507fd5c7bf515
environments/contacts/attribute_key: cc92f647873ba9e17cff57d4a59737bd
environments/contacts/attribute_key_cannot_be_changed: 0ced703e77a8e620276c1fa21fcc8900
environments/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
environments/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f

View File

@@ -167,6 +167,12 @@ export const createEnvironment = async (
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
],
},
},

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attribut erfolgreich erstellt",
"attribute_description": "Beschreibung",
"attribute_description_placeholder": "Kurze Beschreibung",
"attribute_key": "Schlüssel",
"attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "Schlüssel kann nach der Erstellung nicht geändert werden",
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attribute created successfully",
"attribute_description": "Description",
"attribute_description_placeholder": "Short description",
"attribute_key": "Key",
"attribute_key": "Attribute",
"attribute_key_cannot_be_changed": "Key cannot be changed after creation",
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",
@@ -1267,14 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"display_type": "Display type",
"dropdown": "Dropdown",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
"display_number_of_responses_for_survey": "Display number of responses for survey",
"display_type": "Display type",
"divide": "Divide /",
"does_not_contain": "Does not contain",
"does_not_end_with": "Does not end with",
@@ -1282,6 +1281,7 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"dropdown": "Dropdown",
"duplicate_block": "Duplicate block",
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
@@ -1414,11 +1414,11 @@
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
"limit_upload_file_size_to": "Limit upload file size to",
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
"list": "List",
"load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
"logo_settings": "Logo settings",
"list": "List",
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
@@ -3094,4 +3094,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}
}

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Atributo creado con éxito",
"attribute_description": "Descripción",
"attribute_description_placeholder": "Descripción breve",
"attribute_key": "Clave",
"attribute_key": "Atributo",
"attribute_key_cannot_be_changed": "La clave no se puede cambiar después de la creación",
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attribut créé avec succès",
"attribute_description": "Description",
"attribute_description_placeholder": "Brève description",
"attribute_key": "Clé",
"attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "La clé ne peut pas être modifiée après la création",
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "属性を作成しました",
"attribute_description": "説明",
"attribute_description_placeholder": "簡単な説明",
"attribute_key": "キー",
"attribute_key": "属性",
"attribute_key_cannot_be_changed": "キーは作成後に変更できません",
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
"attribute_key_placeholder": "例: date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attribuut succesvol aangemaakt",
"attribute_description": "Beschrijving",
"attribute_description_placeholder": "Korte beschrijving",
"attribute_key": "Sleutel",
"attribute_key": "Kenmerk",
"attribute_key_cannot_be_changed": "Sleutel kan niet worden gewijzigd na aanmaak",
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição",
"attribute_description_placeholder": "Descrição curta",
"attribute_key": "Chave",
"attribute_key": "atributo",
"attribute_key_cannot_be_changed": "A chave não pode ser alterada após a criação",
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição",
"attribute_description_placeholder": "Descrição breve",
"attribute_key": "Chave",
"attribute_key": "Atributo",
"attribute_key_cannot_be_changed": "A chave não pode ser alterada após a criação",
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Atribut creat cu succes",
"attribute_description": "Descriere",
"attribute_description_placeholder": "Descriere scurtă",
"attribute_key": "Cheie",
"attribute_key": "Atribut",
"attribute_key_cannot_be_changed": "Cheia nu poate fi modificată după creare",
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Атрибут успешно создан",
"attribute_description": "Описание",
"attribute_description_placeholder": "Краткое описание",
"attribute_key": "Ключ",
"attribute_key": "Атрибут",
"attribute_key_cannot_be_changed": "Ключ нельзя изменить после создания",
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
"attribute_key_placeholder": "например, date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attributet har skapats",
"attribute_description": "Beskrivning",
"attribute_description_placeholder": "Kort beskrivning",
"attribute_key": "Nyckel",
"attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "Nyckeln kan inte ändras efter skapande",
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "属性创建成功",
"attribute_description": "描述",
"attribute_description_placeholder": "简短描述",
"attribute_key": "",
"attribute_key": "属性",
"attribute_key_cannot_be_changed": "创建后键不可更改",
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
"attribute_key_placeholder": "例如date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "屬性建立成功",
"attribute_description": "描述",
"attribute_description_placeholder": "簡短描述",
"attribute_key": "金鑰",
"attribute_key": "屬性",
"attribute_key_cannot_be_changed": "建立後無法變更金鑰",
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
"attribute_key_placeholder": "例如date_of_birth",

View File

@@ -4,6 +4,34 @@ import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attribut
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
const DEFAULT_ATTRIBUTE_KEYS = ["email", "language", "userId", "firstName", "lastName"] as const;
interface AttributeRowProps {
label: string;
value: string | undefined;
notProvidedText: string;
isIdBadge?: boolean;
}
const AttributeRow = ({ label, value, notProvidedText, isIdBadge = false }: AttributeRowProps) => {
const renderValue = () => {
if (!value) {
return <span className="text-slate-300">{notProvidedText}</span>;
}
if (isIdBadge) {
return <IdBadge id={value} />;
}
return <span>{value}</span>;
};
return (
<div>
<dt className="text-sm font-medium text-slate-500">{label}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{renderValue()}</dd>
</div>
);
};
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
@@ -14,55 +42,34 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
const responses = await getResponsesByContactId(contactId);
const numberOfResponses = responses?.length || 0;
const notProvidedText = t("environments.contacts.not_provided");
const customAttributes = Object.entries(attributes).filter(
([key]) => !DEFAULT_ATTRIBUTE_KEYS.includes(key as (typeof DEFAULT_ATTRIBUTE_KEYS)[number])
);
return (
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">{t("common.attributes")}</h2>
<div>
<dt className="text-sm font-medium text-slate-500">email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.email ? (
<span>{attributes.email}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">language</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.language ? (
<span>{attributes.language}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">userId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<IdBadge id={attributes.userId} />
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<AttributeRow label="email" value={attributes.email} notProvidedText={notProvidedText} />
<AttributeRow label="language" value={attributes.language} notProvidedText={notProvidedText} />
<AttributeRow label="userId" value={attributes.userId} notProvidedText={notProvidedText} isIdBadge />
<AttributeRow label="firstName" value={attributes.firstName} notProvidedText={notProvidedText} />
<AttributeRow label="lastName" value={attributes.lastName} notProvidedText={notProvidedText} />
<div>
<dt className="text-sm font-medium text-slate-500">contactId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
</div>
{Object.entries(attributes)
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
.map(([key, attributeData]) => {
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div>
);
})}
{customAttributes.map(([key, value]) => (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{value}</dd>
</div>
))}
<hr />
<div>

View File

@@ -175,6 +175,23 @@ export const EditContactAttributesModal = ({
setOpen(newOpen);
};
// Memoize email/userId existence checks for delete button logic
// A contact MUST have either email or userId, so we prevent deletion if it would violate this
const { hasEmail, hasUserId } = useMemo(() => {
const emailAttr = watchedAttributes.find((attr) => attr.key === "email");
const userIdAttr = watchedAttributes.find((attr) => attr.key === "userId");
return {
hasEmail: !!emailAttr?.value?.trim(),
hasUserId: !!userIdAttr?.value?.trim(),
};
}, [watchedAttributes]);
const isDeleteDisabled = (key: string): boolean => {
if (key === "email") return !hasUserId;
if (key === "userId") return !hasEmail;
return false;
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent width="default" className="max-h-[90vh]">
@@ -235,7 +252,7 @@ export const EditContactAttributesModal = ({
<Button
type="button"
variant="outline"
disabled={["email", "userId", "firstName", "lastName"].includes(field.key)}
disabled={isDeleteDisabled(field.key)}
size="sm"
onClick={() => handleRemoveAttribute(index)}
className="h-10 w-10 p-0">

View File

@@ -11,30 +11,27 @@ import {
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
// Default/system attributes that should not be deleted even if missing from payload
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
const deleteAttributes = async (
contactId: string,
currentAttributes: TContactAttributes,
submittedAttributes: TContactAttributes,
contactAttributeKeys: TContactAttributeKey[]
): Promise<{ success: boolean }> => {
): Promise<void> => {
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
// Determine which attributes should be deleted (exist in DB but not in payload, and not default attributes)
// Determine which attributes should be deleted (exist in DB but not in payload)
const submittedKeys = new Set(Object.keys(submittedAttributes));
const currentKeys = new Set(Object.keys(currentAttributes));
const keysToDelete = Array.from(currentKeys).filter(
(key) => !submittedKeys.has(key) && !DEFAULT_ATTRIBUTES.has(key)
);
const keysToDelete = Object.keys(currentAttributes).filter((key) => !submittedKeys.has(key));
// Get attribute key IDs for deletion
// Get attribute key IDs for deletion, but exclude default attributes
const attributeKeyIdsToDelete = keysToDelete
.map((key) => contactAttributeKeyMap.get(key)?.id)
.map((key) => {
const attributeKey = contactAttributeKeyMap.get(key);
// Only include non-default attributes for deletion
return attributeKey?.type === "custom" ? attributeKey.id : null;
})
.filter((id): id is string => !!id);
// Delete attributes that were removed from the form (but not default attributes)
if (attributeKeyIdsToDelete.length > 0) {
await prisma.contactAttribute.deleteMany({
where: {
@@ -45,10 +42,6 @@ const deleteAttributes = async (
},
});
}
return {
success: true,
};
};
/**
@@ -161,8 +154,10 @@ export const updateAttributes = async (
// Delete attributes that were removed (only when explicitly requested)
// This is used by UI forms where all attributes are submitted
// For API calls, we want merge behavior by default (only update passed attributes)
// We use contactAttributes (not contactAttributesParam) because it includes validation adjustments
// (e.g., preserving email/userId when both would be empty)
if (deleteRemovedAttributes) {
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
await deleteAttributes(contactId, currentAttributes, contactAttributes, contactAttributeKeys);
}
// Create lookup map for attribute keys

View File

@@ -10,7 +10,11 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TMultipleChoiceOptionDisplayType, TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import {
TMultipleChoiceOptionDisplayType,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
} from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";

View File

@@ -1,4 +1,4 @@
import { v7 as uuidv7 } from "uuid";
import { createId } from "@paralleldrive/cuid2";
import type { SurveyElement, ValidationRule } from "./types";
/**
@@ -62,7 +62,7 @@ export function migrateOpenTextCharLimit(element: SurveyElement): void {
!hasMatchingRule(existingRules, "minLength", { min: element.charLimit.min })
) {
const newRule: ValidationRule = {
id: uuidv7(),
id: createId(),
type: "minLength",
params: { min: element.charLimit.min },
};
@@ -78,7 +78,7 @@ export function migrateOpenTextCharLimit(element: SurveyElement): void {
})
) {
const newRule: ValidationRule = {
id: uuidv7(),
id: createId(),
type: "maxLength",
params: { max: element.charLimit.max },
};
@@ -138,7 +138,7 @@ export function migrateFileUploadExtensions(element: SurveyElement): void {
if (!hasMatchingExtensionRule) {
// Create new fileExtensionIs rule
const newRule: ValidationRule = {
id: uuidv7(),
id: createId(),
type: "fileExtensionIs",
params: { extensions: [...extensions] },
};

View File

@@ -0,0 +1,76 @@
/* eslint-disable no-constant-condition -- Required for the while loop */
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
import { createId } from "@paralleldrive/cuid2";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
export const addLanguageAttributeKey: MigrationScript = {
type: "data",
id: "l4n8g7u6a9g2e0k3e1y4a5d6",
name: "20260121161018_add_language_attribute_key",
run: async ({ tx }) => {
const BATCH_SIZE = 10000;
let skip = 0;
let totalEnvironmentsProcessed = 0;
let totalLanguageKeysAdded = 0;
logger.info("Starting migration to add language attribute key to all environments");
while (true) {
// Fetch environments in batches
const environments = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM "Environment" LIMIT ${BATCH_SIZE} OFFSET ${skip}
`;
if (environments.length === 0) {
break;
}
logger.info(`Processing ${environments.length.toString()} environments (batch starting at ${skip})`);
// Process each environment
for (const env of environments) {
try {
// Insert language attribute key if it doesn't exist
await tx.$executeRaw`
INSERT INTO "ContactAttributeKey" (
"id", "created_at", "updated_at", "key", "name", "description", "type", "isUnique", "environmentId"
) VALUES (
${createId()},
NOW(),
NOW(),
'language',
'Language',
'The language preference of a contact',
'default',
false,
${env.id}
)
ON CONFLICT ("key", "environmentId") DO NOTHING
`;
totalLanguageKeysAdded++;
} catch (error) {
logger.error(`Failed to add language attribute key for environment ${env.id}: ${error}`);
throw error;
}
}
skip += environments.length;
totalEnvironmentsProcessed += environments.length;
}
// Verify migration
const [{ total_language_keys: totalLanguageKeys }] = await tx.$queryRaw<
[{ total_language_keys: number }]
>`
SELECT COUNT(*)::integer AS total_language_keys
FROM "ContactAttributeKey"
WHERE "key" = 'language'
`;
logger.info(`Migration completed successfully!`);
logger.info(`Total environments processed: ${totalEnvironmentsProcessed.toString()}`);
logger.info(`Total language attribute keys in database: ${totalLanguageKeys.toString()}`);
},
};

View File

@@ -455,6 +455,7 @@ async function main(): Promise<void> {
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" },
{ name: "Language", key: "language", isUnique: false, type: "default" },
],
},
},
@@ -474,6 +475,7 @@ async function main(): Promise<void> {
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" },
{ name: "Language", key: "language", isUnique: false, type: "default" },
],
},
},

View File

@@ -148,15 +148,15 @@ function DropdownVariant({
<Button
variant="outline"
disabled={disabled}
className="rounded-input w-full justify-between bg-option-bg rounded-option border border-option-border my-0 h-input"
className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" />
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)] max-h-[300px] overflow-y-auto"
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
align="start">
{options
.filter((option) => option.id !== "none")

View File

@@ -160,15 +160,15 @@ function SingleSelect({
<Button
variant="outline"
disabled={disabled}
className="rounded-input w-full justify-between bg-option-bg rounded-option border border-option-border my-0 h-input"
className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" />
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)] max-h-[300px] overflow-y-auto"
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
align="start">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{options
@@ -193,7 +193,9 @@ function SingleSelect({
id={`${inputId}-${otherOptionId}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{otherValue || otherOptionLabel}</span>
<span className="font-input font-input-weight text-input-text">
{otherValue || otherOptionLabel}
</span>
</DropdownMenuRadioItem>
) : null}
{options
@@ -279,7 +281,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
className={cn("ml-3 mr-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel}
</span>

View File

@@ -29,13 +29,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
"text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</div>
</DropdownMenuPrimitive.Portal >
</DropdownMenuPrimitive.Portal>
);
}
@@ -58,7 +58,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
@@ -76,12 +76,12 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_*]:cursor-pointer",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:cursor-pointer [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center label-headline">
<span className="label-headline pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -104,11 +104,11 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_*]:cursor-pointer",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:cursor-pointer [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center label-headline">
<span className="label-headline pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -175,12 +175,12 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto label-headline size-4" />
<ChevronRightIcon className="label-headline ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}

View File

@@ -22,7 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
style={{ fontSize: "var(--fb-input-font-size)" }}
className={cn(
// Layout and behavior
"flex min-w-0 border transition-[color,box-shadow] outline-none",
"flex min-w-0 border outline-none transition-[color,box-shadow]",
// Customizable styles via CSS variables (using Tailwind theme extensions)
"w-input h-input",
"bg-input-bg border-input-border rounded-input",

View File

@@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}
className={cn(
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text field-sizing-content flex min-h-16 border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -21,6 +21,8 @@
"respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry",
"retrying": "Retrying...",
"select_option": "Select an option",
"select_options": "Select options",
"sending_responses": "Sending responses...",
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
@@ -28,9 +30,7 @@
"terms_of_service": "Terms of Service",
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
"they_will_be_redirected_immediately": "They will be redirected immediately",
"your_feedback_is_stuck": "Your feedback is stuck :(",
"select_option": "Select an option",
"select_options": "Select options"
"your_feedback_is_stuck": "Your feedback is stuck :("
},
"errors": {
"all_options_must_be_ranked": "Please rank all options",
@@ -78,4 +78,4 @@
"value_must_not_contain": "Value must not contain {value}",
"value_must_not_equal": "Value must not equal {value}"
}
}
}

View File

@@ -70,4 +70,4 @@
"vite-plugin-dts": "4.5.3",
"vite-tsconfig-paths": "5.1.4"
}
}
}