diff --git a/.cursor/commands/create-question.md b/.cursor/commands/create-question.md
new file mode 100644
index 0000000000..c34efdcebe
--- /dev/null
+++ b/.cursor/commands/create-question.md
@@ -0,0 +1,352 @@
+# Create New Question Element
+
+Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
+
+## Usage
+
+When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
+
+1. **Create the component file** `{question-type}.tsx` with this structure:
+
+```typescript
+import * as React from "react";
+import { ElementHeader } from "../components/element-header";
+import { useTextDirection } from "../hooks/use-text-direction";
+import { cn } from "../lib/utils";
+
+interface {QuestionType}Props {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main question or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the input/control group */
+ inputId: string;
+ /** Current value */
+ value?: {ValueType};
+ /** Callback function called when the value changes */
+ onChange: (value: {ValueType}) => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the controls are disabled */
+ disabled?: boolean;
+ // Add question-specific props here
+}
+
+function {QuestionType}({
+ elementId,
+ headline,
+ description,
+ inputId,
+ value,
+ onChange,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+ // ... question-specific props
+}: {QuestionType}Props): React.JSX.Element {
+ // Ensure value is always the correct type (handle undefined/null)
+ const currentValue = value ?? {defaultValue};
+
+ // Detect text direction from content
+ const detectedDir = useTextDirection({
+ dir,
+ textContent: [headline, description ?? "", /* add other text content from question */],
+ });
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Question-specific controls */}
+ {/* TODO: Add your question-specific UI here */}
+
+ {/* Error message */}
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+ );
+}
+
+export { {QuestionType} };
+export type { {QuestionType}Props };
+```
+
+2. **Create the Storybook file** `{question-type}.stories.tsx`:
+
+```typescript
+import type { Decorator, Meta, StoryObj } from "@storybook/react";
+import React from "react";
+import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
+
+// Styling options for the StylingPlayground story
+interface StylingOptions {
+ // Question styling
+ questionHeadlineFontFamily: string;
+ questionHeadlineFontSize: string;
+ questionHeadlineFontWeight: string;
+ questionHeadlineColor: string;
+ questionDescriptionFontFamily: string;
+ questionDescriptionFontWeight: string;
+ questionDescriptionFontSize: string;
+ questionDescriptionColor: string;
+ // Add component-specific styling options here
+}
+
+type StoryProps = {QuestionType}Props & Partial;
+
+const meta: Meta = {
+ title: "UI-package/Elements/{QuestionType}",
+ component: {QuestionType},
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component: "A complete {question type} question element...",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ headline: {
+ control: "text",
+ description: "The main question text",
+ table: { category: "Content" },
+ },
+ description: {
+ control: "text",
+ description: "Optional description or subheader text",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "object",
+ description: "Current value",
+ table: { category: "State" },
+ },
+ required: {
+ control: "boolean",
+ description: "Whether the field is required",
+ table: { category: "Validation" },
+ },
+ errorMessage: {
+ control: "text",
+ description: "Error message to display",
+ table: { category: "Validation" },
+ },
+ dir: {
+ control: { type: "select" },
+ options: ["ltr", "rtl", "auto"],
+ description: "Text direction for RTL support",
+ table: { category: "Layout" },
+ },
+ disabled: {
+ control: "boolean",
+ description: "Whether the controls are disabled",
+ table: { category: "State" },
+ },
+ onChange: {
+ action: "changed",
+ table: { category: "Events" },
+ },
+ // Add question-specific argTypes here
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+const withCSSVariables: Decorator = (Story, context) => {
+ const args = context.args as StoryProps;
+ const {
+ questionHeadlineFontFamily,
+ questionHeadlineFontSize,
+ questionHeadlineFontWeight,
+ questionHeadlineColor,
+ questionDescriptionFontFamily,
+ questionDescriptionFontSize,
+ questionDescriptionFontWeight,
+ questionDescriptionColor,
+ // Extract component-specific styling options
+ } = args;
+
+ const cssVarStyle: React.CSSProperties & Record = {
+ "--fb-question-headline-font-family": questionHeadlineFontFamily,
+ "--fb-question-headline-font-size": questionHeadlineFontSize,
+ "--fb-question-headline-font-weight": questionHeadlineFontWeight,
+ "--fb-question-headline-color": questionHeadlineColor,
+ "--fb-question-description-font-family": questionDescriptionFontFamily,
+ "--fb-question-description-font-size": questionDescriptionFontSize,
+ "--fb-question-description-font-weight": questionDescriptionFontWeight,
+ "--fb-question-description-color": questionDescriptionColor,
+ // Add component-specific CSS variables
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const StylingPlayground: Story = {
+ args: {
+ headline: "Example question?",
+ description: "Example description",
+ // Default styling values
+ questionHeadlineFontFamily: "system-ui, sans-serif",
+ questionHeadlineFontSize: "1.125rem",
+ questionHeadlineFontWeight: "600",
+ questionHeadlineColor: "#1e293b",
+ questionDescriptionFontFamily: "system-ui, sans-serif",
+ questionDescriptionFontSize: "0.875rem",
+ questionDescriptionFontWeight: "400",
+ questionDescriptionColor: "#64748b",
+ // Add component-specific default values
+ },
+ argTypes: {
+ // Question styling argTypes
+ questionHeadlineFontFamily: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionHeadlineFontSize: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionHeadlineFontWeight: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionHeadlineColor: {
+ control: "color",
+ table: { category: "Question Styling" },
+ },
+ questionDescriptionFontFamily: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionDescriptionFontSize: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionDescriptionFontWeight: {
+ control: "text",
+ table: { category: "Question Styling" },
+ },
+ questionDescriptionColor: {
+ control: "color",
+ table: { category: "Question Styling" },
+ },
+ // Add component-specific argTypes
+ },
+ decorators: [withCSSVariables],
+};
+
+export const Default: Story = {
+ args: {
+ headline: "Example question?",
+ // Add default props
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "Example question?",
+ description: "Example description text",
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "Example question?",
+ required: true,
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ headline: "Example question?",
+ errorMessage: "This field is required",
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ headline: "Example question?",
+ disabled: true,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ headline: "مثال على السؤال؟",
+ description: "مثال على الوصف",
+ // Add RTL-specific props
+ },
+};
+```
+
+3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
+
+```css
+/* Component-specific CSS variables */
+--fb-{component}-{property}: {default-value};
+```
+
+4. **Export from** `packages/survey-ui/src/index.ts`:
+
+```typescript
+export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
+```
+
+## Key Requirements
+
+- ✅ Always use `ElementHeader` component for headline/description
+- ✅ Always use `useTextDirection` hook for RTL support
+- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
+- ✅ Always include error message display if applicable
+- ✅ Always support disabled state if applicable
+- ✅ Always add JSDoc comments to props interface
+- ✅ Always create Storybook stories with styling playground
+- ✅ Always export types from component file
+- ✅ Always add to index.ts exports
+
+## Examples
+
+- `open-text.tsx` - Text input/textarea question (string value)
+- `multi-select.tsx` - Multiple checkbox selection (string[] value)
+
+## Checklist
+
+When creating a new question element, verify:
+
+- [ ] Component file created with proper structure
+- [ ] Props interface with JSDoc comments for all props
+- [ ] Uses `ElementHeader` component (don't duplicate header logic)
+- [ ] Uses `useTextDirection` hook for RTL support
+- [ ] Handles undefined/null values safely
+- [ ] Storybook file created with styling playground
+- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
+- [ ] CSS variables added to `globals.css` if component needs custom styling
+- [ ] Exported from `index.ts` with types
+- [ ] TypeScript types properly exported
+- [ ] Error message display included if applicable
+- [ ] Disabled state supported if applicable
+
diff --git a/.env.example b/.env.example
index f67d8a0e6d..81f846846c 100644
--- a/.env.example
+++ b/.env.example
@@ -9,8 +9,12 @@
WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
+# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
NEXTAUTH_URL=http://localhost:3000
+# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
+# BASE_PATH=
+
# Encryption keys
# Please set both for now, we will change this in the future
diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts
index fa597f552f..0b16b26f64 100644
--- a/apps/storybook/.storybook/main.ts
+++ b/apps/storybook/.storybook/main.ts
@@ -1,8 +1,11 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
-import { dirname, join } from "path";
+import { dirname, join, resolve } from "path";
+import { fileURLToPath } from "url";
const require = createRequire(import.meta.url);
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
/**
* This function is used to resolve the absolute path of a package.
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
- stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
+ stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
+ async viteFinal(config) {
+ const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
+ const rootPath = resolve(__dirname, "../../../");
+
+ // Configure server to allow files from outside the storybook directory
+ config.server = config.server || {};
+ config.server.fs = {
+ ...config.server.fs,
+ allow: [...(config.server.fs?.allow || []), rootPath],
+ };
+
+ // Configure simple alias resolution
+ config.resolve = config.resolve || {};
+ config.resolve.alias = {
+ ...config.resolve.alias,
+ "@": surveyUiPath,
+ };
+
+ return config;
+ },
};
export default config;
diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts
index b0c7224444..58c0444d37 100644
--- a/apps/storybook/.storybook/preview.ts
+++ b/apps/storybook/.storybook/preview.ts
@@ -1,19 +1,6 @@
import type { Preview } from "@storybook/react-vite";
import React from "react";
-import { I18nProvider } from "../../web/lingodotdev/client";
-import "../../web/modules/ui/globals.css";
-
-// Create a Storybook-specific Lingodot Dev decorator
-const withLingodotDev = (Story: any) => {
- return React.createElement(
- I18nProvider,
- {
- language: "en-US",
- defaultLanguage: "en-US",
- } as any,
- React.createElement(Story)
- );
-};
+import "../../../packages/survey-ui/src/styles/globals.css";
const preview: Preview = {
parameters: {
@@ -22,9 +9,23 @@ const preview: Preview = {
color: /(background|color)$/i,
date: /Date$/i,
},
+ expanded: true,
+ },
+ backgrounds: {
+ default: "light",
},
},
- decorators: [withLingodotDev],
+ decorators: [
+ (Story) =>
+ React.createElement(
+ "div",
+ {
+ id: "fbjs",
+ className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
+ },
+ React.createElement(Story)
+ ),
+ ],
};
export default preview;
diff --git a/apps/storybook/package.json b/apps/storybook/package.json
index 85ff2a6110..5b32e7d321 100644
--- a/apps/storybook/package.json
+++ b/apps/storybook/package.json
@@ -11,22 +11,24 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
- "eslint-plugin-react-refresh": "0.4.20"
+ "@formbricks/survey-ui": "workspace:*",
+ "eslint-plugin-react-refresh": "0.4.24"
},
"devDependencies": {
- "@chromatic-com/storybook": "^4.0.1",
- "@storybook/addon-a11y": "9.0.15",
- "@storybook/addon-links": "9.0.15",
- "@storybook/addon-onboarding": "9.0.15",
- "@storybook/react-vite": "9.0.15",
- "@typescript-eslint/eslint-plugin": "8.32.0",
- "@typescript-eslint/parser": "8.32.0",
- "@vitejs/plugin-react": "4.4.1",
- "esbuild": "0.25.4",
- "eslint-plugin-storybook": "9.0.15",
+ "@chromatic-com/storybook": "^4.1.3",
+ "@storybook/addon-a11y": "10.0.8",
+ "@storybook/addon-links": "10.0.8",
+ "@storybook/addon-onboarding": "10.0.8",
+ "@storybook/react-vite": "10.0.8",
+ "@typescript-eslint/eslint-plugin": "8.48.0",
+ "@tailwindcss/vite": "4.1.17",
+ "@typescript-eslint/parser": "8.48.0",
+ "@vitejs/plugin-react": "5.1.1",
+ "esbuild": "0.27.0",
+ "eslint-plugin-storybook": "10.0.8",
"prop-types": "15.8.1",
- "storybook": "9.0.15",
- "vite": "6.4.1",
- "@storybook/addon-docs": "9.0.15"
+ "storybook": "10.0.8",
+ "vite": "7.2.4",
+ "@storybook/addon-docs": "10.0.8"
}
}
diff --git a/apps/storybook/postcss.config.js b/apps/storybook/postcss.config.js
deleted file mode 100644
index 2aa7205d4b..0000000000
--- a/apps/storybook/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
- plugins: {
- tailwindcss: {},
- autoprefixer: {},
- },
-};
diff --git a/apps/storybook/tailwind.config.js b/apps/storybook/tailwind.config.js
index 574e3b7b54..3b65545a60 100644
--- a/apps/storybook/tailwind.config.js
+++ b/apps/storybook/tailwind.config.js
@@ -1,7 +1,15 @@
/** @type {import('tailwindcss').Config} */
-import base from "../web/tailwind.config";
+import surveyUi from "../../packages/survey-ui/tailwind.config";
export default {
- ...base,
- content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ "../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ ...surveyUi.theme?.extend,
+ },
+ },
};
diff --git a/apps/storybook/vite.config.ts b/apps/storybook/vite.config.ts
index 37337dae71..f4068dd20b 100644
--- a/apps/storybook/vite.config.ts
+++ b/apps/storybook/vite.config.ts
@@ -1,16 +1,17 @@
+import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), tailwindcss()],
define: {
"process.env": {},
},
resolve: {
alias: {
- "@": path.resolve(__dirname, "../web"),
+ "@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
},
},
});
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 5febe57594..e9c50266d6 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -37,6 +37,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH
+# Base path for the application (optional)
+ARG BASE_PATH=""
+ENV BASE_PATH=${BASE_PATH}
+
# Set the working directory
WORKDIR /app
diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx
index 27048e46aa..64d5ed60d1 100644
--- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx
+++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings.tsx
@@ -44,6 +44,7 @@ interface ProjectSettingsProps {
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
+ publicDomain: string;
}
export const ProjectSettings = ({
@@ -55,6 +56,7 @@ export const ProjectSettings = ({
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
+ publicDomain,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -231,6 +233,7 @@ export const ProjectSettings = ({
{t("common.preview")}
{
throw new Error(t("common.organization_teams_not_found"));
}
+ const publicDomain = getPublicDomain();
+
return (
{
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
+ publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{
const t = await getTranslate();
+ const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
@@ -72,6 +74,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
+ publicDomain={publicDomain}
/>
{
const router = useRouter();
const pathname = usePathname();
@@ -286,15 +288,16 @@ export const MainNavigation = ({
{/* Logout */}
{
+ const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
- redirectUrl: "/auth/login",
+ redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
- callbackUrl: "/auth/login",
+ callbackUrl: loginUrl,
clearEnvironmentId: true,
});
- router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
+ router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
}}
icon={ }>
{t("common.logout")}
diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts
index f75f5709f0..0d1be9f08b 100644
--- a/apps/web/lib/env.ts
+++ b/apps/web/lib/env.ts
@@ -44,6 +44,7 @@ export const env = createEnv({
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
+ NEXTAUTH_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -168,6 +169,7 @@ export const env = createEnv({
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
+ NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
diff --git a/apps/web/modules/auth/verify/components/sign-in.tsx b/apps/web/modules/auth/verify/components/sign-in.tsx
index 931e86bb20..82268930a3 100644
--- a/apps/web/modules/auth/verify/components/sign-in.tsx
+++ b/apps/web/modules/auth/verify/components/sign-in.tsx
@@ -3,15 +3,15 @@
import { signIn } from "next-auth/react";
import { useEffect } from "react";
-export const SignIn = ({ token }) => {
+export const SignIn = ({ token, webAppUrl }) => {
useEffect(() => {
if (token) {
signIn("token", {
token: token,
- callbackUrl: `/`,
+ callbackUrl: webAppUrl,
});
}
- }, [token]);
+ }, [token, webAppUrl]);
return <>>;
};
diff --git a/apps/web/modules/auth/verify/page.tsx b/apps/web/modules/auth/verify/page.tsx
index 0c4881e0bc..5c988d2be8 100644
--- a/apps/web/modules/auth/verify/page.tsx
+++ b/apps/web/modules/auth/verify/page.tsx
@@ -1,3 +1,4 @@
+import { WEBAPP_URL } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
import { SignIn } from "@/modules/auth/verify/components/sign-in";
@@ -9,7 +10,7 @@ export const VerifyPage = async ({ searchParams }) => {
return token ? (
{t("auth.verify.verifying")}
-
+
) : (
{t("auth.verify.no_token_provided")}
diff --git a/apps/web/modules/email/emails/lib/utils.tsx b/apps/web/modules/email/emails/lib/utils.tsx
index 13903ce0aa..3371ad1d26 100644
--- a/apps/web/modules/email/emails/lib/utils.tsx
+++ b/apps/web/modules/email/emails/lib/utils.tsx
@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
return (
{overrideFileUploadResponse ? (
-
+
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
) : (
@@ -65,6 +65,6 @@ export const renderEmailResponseValue = async (
);
default:
- return {response} ;
+ return {response} ;
}
};
diff --git a/apps/web/modules/email/emails/survey/response-finished-email.tsx b/apps/web/modules/email/emails/survey/response-finished-email.tsx
index 73688ad89d..89afb04852 100644
--- a/apps/web/modules/email/emails/survey/response-finished-email.tsx
+++ b/apps/web/modules/email/emails/survey/response-finished-email.tsx
@@ -74,7 +74,7 @@ export async function ResponseFinishedEmail({
)}
{variable.name}
-
+
{variableResponse}
@@ -94,7 +94,7 @@ export async function ResponseFinishedEmail({
{hiddenFieldId}
-
+
{hiddenFieldResponse}
diff --git a/apps/web/modules/projects/settings/look/components/theme-styling.tsx b/apps/web/modules/projects/settings/look/components/theme-styling.tsx
index 92fac8e706..ab5a5cd66d 100644
--- a/apps/web/modules/projects/settings/look/components/theme-styling.tsx
+++ b/apps/web/modules/projects/settings/look/components/theme-styling.tsx
@@ -38,6 +38,7 @@ interface ThemeStylingProps {
isUnsplashConfigured: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
+ publicDomain: string;
}
export const ThemeStyling = ({
@@ -47,6 +48,7 @@ export const ThemeStyling = ({
isUnsplashConfigured,
isReadOnly,
isStorageConfigured = true,
+ publicDomain,
}: ThemeStylingProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -199,6 +201,7 @@ export const ThemeStyling = ({
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
+ publicDomain={publicDomain}
/>
diff --git a/apps/web/modules/projects/settings/look/page.tsx b/apps/web/modules/projects/settings/look/page.tsx
index 0824f7ae1e..fec06b55c0 100644
--- a/apps/web/modules/projects/settings/look/page.tsx
+++ b/apps/web/modules/projects/settings/look/page.tsx
@@ -1,6 +1,7 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
+import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
@@ -27,6 +28,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
}
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
+ const publicDomain = getPublicDomain();
return (
@@ -49,6 +51,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
+ publicDomain={publicDomain}
/>
diff --git a/apps/web/modules/survey/editor/components/edit-welcome-card.tsx b/apps/web/modules/survey/editor/components/edit-welcome-card.tsx
index 9d772ae1d1..57a0f207c8 100644
--- a/apps/web/modules/survey/editor/components/edit-welcome-card.tsx
+++ b/apps/web/modules/survey/editor/components/edit-welcome-card.tsx
@@ -22,6 +22,7 @@ interface EditWelcomeCardProps {
setSelectedLanguageCode: (languageCode: string) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
+ isExternalUrlsAllowed?: boolean;
}
export const EditWelcomeCard = ({
@@ -34,6 +35,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
+ isExternalUrlsAllowed,
}: EditWelcomeCardProps) => {
const { t } = useTranslation();
@@ -65,7 +67,7 @@ export const EditWelcomeCard = ({
@@ -135,6 +137,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
+ isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
@@ -150,6 +153,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
+ isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
@@ -170,6 +174,7 @@ export const EditWelcomeCard = ({
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
+ isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
diff --git a/apps/web/modules/survey/editor/components/elements-view.tsx b/apps/web/modules/survey/editor/components/elements-view.tsx
index 13f346ef2b..fced0901ce 100644
--- a/apps/web/modules/survey/editor/components/elements-view.tsx
+++ b/apps/web/modules/survey/editor/components/elements-view.tsx
@@ -808,6 +808,7 @@ export const ElementsView = ({
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
+ isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
)}
diff --git a/apps/web/modules/survey/editor/components/survey-editor.tsx b/apps/web/modules/survey/editor/components/survey-editor.tsx
index 0d96f8611c..249b4e74ce 100644
--- a/apps/web/modules/survey/editor/components/survey-editor.tsx
+++ b/apps/web/modules/survey/editor/components/survey-editor.tsx
@@ -50,6 +50,7 @@ interface SurveyEditorProps {
isStorageConfigured: boolean;
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
+ publicDomain: string;
}
export const SurveyEditor = ({
@@ -79,6 +80,7 @@ export const SurveyEditor = ({
isStorageConfigured,
quotas,
isExternalUrlsAllowed,
+ publicDomain,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState("elements");
const [activeElementId, setActiveElementId] = useState(null);
@@ -272,6 +274,7 @@ export const SurveyEditor = ({
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
isSpamProtectionAllowed={isSpamProtectionAllowed}
+ publicDomain={publicDomain}
/>
diff --git a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
index 02caec3a75..3195a1194e 100644
--- a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
+++ b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx
@@ -400,7 +400,7 @@ export const SurveyMenuBar = ({
/>
-
+
{!isStorageConfigured && (
diff --git a/apps/web/modules/survey/editor/page.tsx b/apps/web/modules/survey/editor/page.tsx
index b37dcd3bba..eeaf5e3d36 100644
--- a/apps/web/modules/survey/editor/page.tsx
+++ b/apps/web/modules/survey/editor/page.tsx
@@ -6,6 +6,7 @@ import {
SURVEY_BG_COLORS,
UNSPLASH_ACCESS_KEY,
} from "@/lib/constants";
+import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
@@ -105,6 +106,7 @@ export const SurveyEditorPage = async (props) => {
}
const isCxMode = searchParams.mode === "cx";
+ const publicDomain = getPublicDomain();
return (
{
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
+ publicDomain={publicDomain}
/>
);
};
diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
index e3aecc0795..62eb62834f 100644
--- a/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
+++ b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx
@@ -84,7 +84,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise
-
+
{variableResponse}
@@ -107,7 +107,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise
{t("emails.hidden_field")}: {hiddenFieldId}
-
+
{hiddenFieldResponse}
diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx
index 9e6c07908f..c7a088f6ea 100644
--- a/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx
+++ b/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx
@@ -155,7 +155,7 @@ export const FollowUpItem = ({
-
+
);
diff --git a/apps/web/modules/survey/templates/components/template-container.tsx b/apps/web/modules/survey/templates/components/template-container.tsx
index 1c0c3580cb..352bfdc7fe 100644
--- a/apps/web/modules/survey/templates/components/template-container.tsx
+++ b/apps/web/modules/survey/templates/components/template-container.tsx
@@ -16,6 +16,7 @@ type TemplateContainerWithPreviewProps = {
environment: Pick;
userId: string;
isTemplatePage?: boolean;
+ publicDomain: string;
};
export const TemplateContainerWithPreview = ({
@@ -23,6 +24,7 @@ export const TemplateContainerWithPreview = ({
environment,
userId,
isTemplatePage = true,
+ publicDomain,
}: TemplateContainerWithPreviewProps) => {
const { t } = useTranslation();
const initialTemplate = customSurveyTemplate(t);
@@ -72,6 +74,7 @@ export const TemplateContainerWithPreview = ({
environment={environment}
languageCode={"default"}
isSpamProtectionAllowed={false} // setting it to false as this is a template
+ publicDomain={publicDomain}
/>
)}
diff --git a/apps/web/modules/survey/templates/page.tsx b/apps/web/modules/survey/templates/page.tsx
index dabc6ac057..f4f63d9d9a 100644
--- a/apps/web/modules/survey/templates/page.tsx
+++ b/apps/web/modules/survey/templates/page.tsx
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
+import { getPublicDomain } from "@/lib/getPublicUrl";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
@@ -27,7 +28,14 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
return redirect(`/environments/${environment.id}/surveys`);
}
+ const publicDomain = getPublicDomain();
+
return (
-
+
);
};
diff --git a/apps/web/modules/ui/components/preview-survey/index.tsx b/apps/web/modules/ui/components/preview-survey/index.tsx
index 3b7f5cbac9..9f23d80132 100644
--- a/apps/web/modules/ui/components/preview-survey/index.tsx
+++ b/apps/web/modules/ui/components/preview-survey/index.tsx
@@ -25,6 +25,7 @@ interface PreviewSurveyProps {
environment: Pick;
languageCode: string;
isSpamProtectionAllowed: boolean;
+ publicDomain: string;
}
let surveyNameTemp: string;
@@ -38,6 +39,7 @@ export const PreviewSurvey = ({
environment,
languageCode,
isSpamProtectionAllowed,
+ publicDomain,
}: PreviewSurveyProps) => {
const [isModalOpen, setIsModalOpen] = useState(true);
const [isFullScreenPreview, setIsFullScreenPreview] = useState(false);
@@ -244,6 +246,7 @@ export const PreviewSurvey = ({
borderRadius={styling?.roundness ?? 8}
background={styling?.cardBackgroundColor?.light}>
@@ -52,7 +52,7 @@ const SelectLabel: React.ComponentType = React
>(({ className, ...props }, ref) => (
));
@@ -65,7 +65,7 @@ const SelectItem: React.ComponentType = React.f
diff --git a/apps/web/modules/ui/components/survey/index.tsx b/apps/web/modules/ui/components/survey/index.tsx
index 56529226a8..e3bc83e247 100644
--- a/apps/web/modules/ui/components/survey/index.tsx
+++ b/apps/web/modules/ui/components/survey/index.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
import { executeRecaptcha, loadRecaptchaScript } from "@/modules/ui/components/survey/recaptcha";
@@ -37,7 +39,8 @@ export const SurveyInline = (props: Omit) =
// Set loading flag immediately to prevent concurrent loads
isLoadingScript = true;
try {
- const response = await fetch("/js/surveys.umd.cjs");
+ const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
+ const response = await fetch(scriptUrl);
if (!response.ok) {
throw new Error("Failed to load the surveys package");
diff --git a/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx b/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx
index 2f2607144e..bff7b4d47d 100644
--- a/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx
+++ b/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx
@@ -16,6 +16,7 @@ interface ThemeStylingPreviewSurveyProps {
project: Project;
previewType: TSurveyType;
setPreviewType: (type: TSurveyType) => void;
+ publicDomain: string;
}
const previewParentContainerVariant: Variants = {
@@ -50,6 +51,7 @@ export const ThemeStylingPreviewSurvey = ({
project,
previewType,
setPreviewType,
+ publicDomain,
}: ThemeStylingPreviewSurveyProps) => {
const [isFullScreenPreview] = useState(false);
const [previewPosition] = useState("relative");
@@ -166,6 +168,7 @@ export const ThemeStylingPreviewSurvey = ({
borderRadius={project.styling.roundness ?? 8}>
{
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
+ basePath: process.env.BASE_PATH || undefined,
output: "standalone",
poweredByHeader: false,
productionBrowserSourceMaps: true,
@@ -403,7 +404,7 @@ const nextConfig = {
];
},
env: {
- NEXTAUTH_URL: process.env.WEBAPP_URL,
+ NEXTAUTH_URL: process.env.NEXTAUTH_URL, // TODO: Remove this once we have a proper solution for the base path
},
};
@@ -441,4 +442,7 @@ const sentryOptions = {
// Runtime Sentry reporting still depends on DSN being set via environment variables
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
+console.log("BASE PATH", nextConfig.basePath);
+
+
export default exportConfig;
diff --git a/apps/web/package.json b/apps/web/package.json
index 20e05713b0..5edbc13399 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -72,8 +72,8 @@
"@radix-ui/react-tooltip": "1.2.6",
"@react-email/components": "0.0.38",
"@sentry/nextjs": "10.5.0",
- "@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/forms": "0.5.10",
+ "@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@ungap/structured-clone": "1.3.0",
@@ -116,6 +116,7 @@
"react-day-picker": "9.6.7",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
+ "react-calendar": "5.1.0",
"react-i18next": "15.7.3",
"react-turnstile": "1.1.4",
"react-use": "17.6.0",
diff --git a/apps/web/playwright/js.spec.ts b/apps/web/playwright/js.spec.ts
index 980a3e6b84..3b0426688c 100644
--- a/apps/web/playwright/js.spec.ts
+++ b/apps/web/playwright/js.spec.ts
@@ -115,12 +115,12 @@ test.describe("JS Package Test", async () => {
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
await page
.locator("#questionCard-3")
- .getByLabel("textarea")
+ .getByRole("textbox")
.fill("People who believe that PMF is necessary");
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
- await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
+ await page.locator("#questionCard-4").getByRole("textbox").fill("Much higher response rates!");
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
- await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
+ await page.locator("#questionCard-5").getByRole("textbox").fill("Make this end to end test pass!");
await page.locator("#questionCard-5").getByRole("button", { name: "Finish" }).click();
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts
index 554c2ef023..43f2a6bc12 100644
--- a/apps/web/playwright/survey.spec.ts
+++ b/apps/web/playwright/survey.spec.ts
@@ -113,10 +113,12 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
).toBeVisible();
- expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
+ // Rating component uses fieldset with labels, not a group with name "Choices"
+ expect(await page.locator("#questionCard-3").locator("fieldset label").count()).toBe(5);
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
- await page.getByRole("radio", { name: "Rate 3 out of" }).check();
+ // Click on the label instead of the radio to avoid SVG intercepting pointer events
+ await page.locator("#questionCard-3").locator('label:has(input[value="3"])').click();
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -165,9 +167,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
await expect(page.getByText(surveys.createAndSubmit.fileUploadQuestion.question)).toBeVisible();
await expect(page.locator("#questionCard-8").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-8").getByRole("button", { name: "Back" })).toBeVisible();
- await expect(
- page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
- ).toBeVisible();
+ await expect(page.getByRole("button", { name: "Upload files by clicking or" })).toBeVisible();
await page.locator("input[type=file]").setInputFiles({
name: "file.doc",
@@ -191,22 +191,22 @@ test.describe("Survey Create & Submit Response without logic", async () => {
page.getByRole("rowheader", { name: surveys.createAndSubmit.matrix.rows[2] })
).toBeVisible();
await expect(
- page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[0] })
+ page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0], exact: true })
).toBeVisible();
await expect(
- page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[1] })
+ page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1], exact: true })
).toBeVisible();
await expect(
- page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[2] })
+ page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2], exact: true })
).toBeVisible();
await expect(
- page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[3] })
+ page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3], exact: true })
).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
- await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
- await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
- await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
+ await page.getByRole("radio", { name: "Roses-0" }).click();
+ await page.getByRole("radio", { name: "Trees-0" }).click();
+ await page.getByRole("radio", { name: "Ocean-0" }).click();
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
// Address Question
@@ -858,7 +858,8 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
- await page.getByRole("radio", { name: "Rate 4 out of" }).check();
+ // Click on the label instead of the radio to avoid SVG intercepting pointer events
+ await page.locator("#questionCard-4").locator('label:has(input[value="4"])').click();
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
// NPS Question
@@ -895,22 +896,22 @@ test.describe("Testing Survey with advanced logic", async () => {
page.getByRole("rowheader", { name: surveys.createWithLogicAndSubmit.matrix.rows[2] })
).toBeVisible();
await expect(
- page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[0] })
+ page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0], exact: true })
).toBeVisible();
await expect(
- page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[1] })
+ page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1], exact: true })
).toBeVisible();
await expect(
- page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[2] })
+ page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2], exact: true })
).toBeVisible();
await expect(
- page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[3] })
+ page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3], exact: true })
).toBeVisible();
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible();
- await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
- await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
- await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
+ await page.getByRole("radio", { name: "Roses-0" }).click();
+ await page.getByRole("radio", { name: "Trees-0" }).click();
+ await page.getByRole("radio", { name: "Ocean-0" }).click();
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
// CTA Question
@@ -939,9 +940,9 @@ test.describe("Testing Survey with advanced logic", async () => {
).toBeVisible();
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Next" })).toBeVisible();
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Back" })).toBeVisible();
- await expect(
- page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
- ).toBeVisible();
+
+ await expect(page.getByRole("button", { name: "Upload files by clicking or" })).toBeVisible();
+
await page.locator("input[type=file]").setInputFiles({
name: "file.doc",
mimeType: "application/msword",
@@ -952,11 +953,10 @@ test.describe("Testing Survey with advanced logic", async () => {
// Date Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.date.question)).toBeVisible();
- await page.getByText("Select a date").click();
- const date = new Date().getDate();
- const month = new Date().toLocaleString("default", { month: "long" });
- await page.getByRole("button", { name: `${month} ${date},` }).click();
- await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
+ // Click the "Today" button in the date picker - matches format like "Today, Tuesday, December 16th,"
+ await page.getByRole("button", { name: /^Today,/ }).click();
+ await page.getByRole("button", { name: "Scroll to bottom" }).click();
+ await page.locator("#questionCard-11").getByRole("button", { name: "Next", exact: true }).click();
// Cal Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.cal.question)).toBeVisible();
diff --git a/package.json b/package.json
index 3f46fcb724..458049c86f 100644
--- a/package.json
+++ b/package.json
@@ -42,8 +42,8 @@
"i18n:validate": "pnpm scan-translations"
},
"dependencies": {
- "react": "19.1.2",
- "react-dom": "19.1.2"
+ "react": "19.2.1",
+ "react-dom": "19.2.1"
},
"devDependencies": {
"@azure/identity": "4.13.0",
@@ -80,9 +80,6 @@
"showDetails": true
},
"pnpm": {
- "patchedDependencies": {
- "next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
- },
"overrides": {
"axios": ">=1.12.2",
"node-forge": ">=1.3.2",
@@ -91,6 +88,9 @@
},
"comments": {
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
+ },
+ "patchedDependencies": {
+ "next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
}
}
}
diff --git a/packages/survey-ui/.eslintrc.cjs b/packages/survey-ui/.eslintrc.cjs
new file mode 100644
index 0000000000..f27b79b57a
--- /dev/null
+++ b/packages/survey-ui/.eslintrc.cjs
@@ -0,0 +1,5 @@
+module.exports = {
+ extends: ["@formbricks/eslint-config/react.js"],
+ ignorePatterns: ["**/*.stories.tsx", "**/*.stories.ts", "story-helpers.tsx", "**/*.test.ts"],
+};
+
diff --git a/packages/survey-ui/.gitignore b/packages/survey-ui/.gitignore
new file mode 100644
index 0000000000..7b65e978ca
--- /dev/null
+++ b/packages/survey-ui/.gitignore
@@ -0,0 +1,8 @@
+node_modules
+dist
+.turbo
+coverage
+*.log
+src/**/*.d.ts
+src/**/*.d.ts.map
+
diff --git a/packages/survey-ui/README.md b/packages/survey-ui/README.md
new file mode 100644
index 0000000000..994a77ecab
--- /dev/null
+++ b/packages/survey-ui/README.md
@@ -0,0 +1,101 @@
+# @formbricks/survey-ui
+
+Reusable UI components package for Formbricks applications.
+
+## Installation
+
+This package is part of the Formbricks monorepo and is available as a workspace dependency.
+
+## Usage
+
+```tsx
+import { Button } from "@formbricks/survey-ui";
+
+function MyComponent() {
+ return (
+
+ Click me
+
+ );
+}
+```
+
+## Development
+
+```bash
+# Build the package
+pnpm build
+
+# Watch mode for development
+pnpm dev
+
+
+# Lint
+pnpm lint
+```
+
+## Structure
+
+```text
+src/
+├── components/ # React components
+├── lib/ # Utility functions
+└── index.ts # Main entry point
+```
+
+## Adding New Components
+
+### Using shadcn CLI (Recommended)
+
+This package is configured to work with shadcn/ui CLI. You can add components using:
+
+```bash
+cd packages/survey-ui
+pnpm ui:add
+```
+
+**Important**: After adding a component, reorganize it into a folder structure:
+
+For example:
+```bash
+pnpm ui:add button
+pnpm ui:organize button
+```
+
+Then export the component from `src/components/index.ts`.
+
+### Manual Component Creation
+
+1. Create a new component directory under `src/components//`
+2. Create `index.tsx` inside that directory
+3. Export the component from `src/components/index.ts`
+4. The component will be available from the main package export
+
+## Component Structure
+
+Components follow this folder structure:
+
+```text
+src/components/
+├── button.tsx
+├── button.stories.tsx
+```
+
+## Theming
+
+This package uses CSS variables for theming. The theme can be customized by modifying `src/styles/globals.css`.
+
+Both light and dark modes are supported out of the box.
+
+## CSS Scoping
+
+By default, this package builds CSS scoped to `#fbjs` for use in the surveys package. This ensures proper specificity and prevents conflicts with preflight CSS.
+
+To build unscoped CSS (e.g., for standalone usage or Storybook), set the `SURVEY_UI_UNSCOPED` environment variable:
+
+```bash
+SURVEY_UI_UNSCOPED=true pnpm build
+```
+
+**Note:** Storybook imports the source CSS directly and compiles it with its own Tailwind config, so it's not affected by this scoping setting.
+
diff --git a/packages/survey-ui/components.json b/packages/survey-ui/components.json
new file mode 100644
index 0000000000..524a1d8409
--- /dev/null
+++ b/packages/survey-ui/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "aliases": {
+ "components": "@/components",
+ "hooks": "@/hooks",
+ "lib": "@/lib",
+ "ui": "@/components",
+ "utils": "@/lib/utils"
+ },
+ "rsc": false,
+ "style": "new-york",
+ "tailwind": {
+ "baseColor": "slate",
+ "config": "tailwind.config.ts",
+ "css": "src/styles/globals.css",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "tsx": true
+}
diff --git a/packages/survey-ui/package.json b/packages/survey-ui/package.json
new file mode 100644
index 0000000000..9c50389f12
--- /dev/null
+++ b/packages/survey-ui/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@formbricks/survey-ui",
+ "license": "MIT",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Reusable UI components for Formbricks applications",
+ "homepage": "https://formbricks.com",
+ "type": "module",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/formbricks/formbricks"
+ },
+ "sideEffects": false,
+ "source": "src/index.ts",
+ "main": "dist/index.js",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./styles": "./dist/survey-ui.css"
+ },
+ "scripts": {
+ "dev": "vite build --watch --mode dev",
+ "build": "vite build",
+ "build:dev": "vite build --mode dev",
+ "go": "vite build --watch --mode dev",
+ "lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
+ "preview": "vite preview",
+ "clean": "rimraf .turbo node_modules dist coverage",
+ "ui:add": "npx shadcn@latest add",
+ "test": "vitest",
+ "test:coverage": "vitest run --coverage"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "dependencies": {
+ "@formkit/auto-animate": "0.8.2",
+ "@radix-ui/react-checkbox": "1.3.1",
+ "@radix-ui/react-dropdown-menu": "2.1.14",
+ "@radix-ui/react-popover": "1.1.13",
+ "@radix-ui/react-progress": "1.1.8",
+ "@radix-ui/react-radio-group": "1.3.6",
+ "@radix-ui/react-slot": "1.2.2",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "date-fns": "4.1.0",
+ "isomorphic-dompurify": "2.33.0",
+ "lucide-react": "0.507.0",
+ "react-day-picker": "9.6.7",
+ "tailwind-merge": "3.2.0"
+ },
+ "devDependencies": {
+ "@formbricks/config-typescript": "workspace:*",
+ "@formbricks/eslint-config": "workspace:*",
+ "@storybook/react": "8.5.4",
+ "@storybook/react-vite": "8.5.4",
+ "@tailwindcss/postcss": "4.0.0",
+ "@tailwindcss/vite": "4.1.17",
+ "@types/react": "19.2.1",
+ "@types/react-dom": "19.2.1",
+ "@vitejs/plugin-react": "4.3.4",
+ "react": "19.2.1",
+ "react-dom": "19.2.1",
+ "rimraf": "6.0.1",
+ "tailwindcss": "4.1.1",
+ "vite": "6.4.1",
+ "vite-plugin-dts": "4.5.3",
+ "vite-tsconfig-paths": "5.1.4",
+ "@vitest/coverage-v8": "3.1.3",
+ "vitest": "3.1.3"
+ }
+}
diff --git a/packages/survey-ui/postcss.config.mjs b/packages/survey-ui/postcss.config.mjs
new file mode 100644
index 0000000000..27ae1c90b5
--- /dev/null
+++ b/packages/survey-ui/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+};
\ No newline at end of file
diff --git a/packages/survey-ui/src/components/elements/consent.stories.tsx b/packages/survey-ui/src/components/elements/consent.stories.tsx
new file mode 100644
index 0000000000..ecc0e8288f
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/consent.stories.tsx
@@ -0,0 +1,188 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ type BaseStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+ elementStylingArgTypes,
+ inputStylingArgTypes,
+ pickArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { Consent, type ConsentProps } from "./consent";
+
+type StoryProps = ConsentProps & Partial & Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/Consent",
+ component: Consent,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A consent element that displays a checkbox for users to accept terms, conditions, or agreements.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ checkboxLabel: {
+ control: "text",
+ description: "Label text for the consent checkbox",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "boolean",
+ description: "Whether consent is checked",
+ table: { category: "State" },
+ },
+ },
+ render: createStatefulRender(Consent),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const StylingPlayground: Story = {
+ args: {
+ elementId: "consent-1",
+ inputId: "consent-input-1",
+ headline: "Terms and Conditions",
+ description: "Please read and accept the terms",
+ checkboxLabel: "I agree to the terms and conditions",
+ onChange: () => {},
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...pickArgTypes(inputStylingArgTypes, [
+ "inputBgColor",
+ "inputBorderColor",
+ "inputColor",
+ "inputFontSize",
+ "inputFontWeight",
+ "inputWidth",
+ "inputBorderRadius",
+ "inputPaddingX",
+ "inputPaddingY",
+ ]),
+ ...surveyStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ elementId: "consent-1",
+ inputId: "consent-input-1",
+ headline: "Terms and Conditions",
+ checkboxLabel: "I agree to the terms and conditions",
+ onChange: () => {},
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ elementId: "consent-2",
+ inputId: "consent-input-2",
+ headline: "Terms and Conditions",
+ description: "Please read and accept the terms to continue",
+ checkboxLabel: "I agree to the terms and conditions",
+ onChange: () => {},
+ },
+};
+
+export const WithConsent: Story = {
+ args: {
+ elementId: "consent-3",
+ inputId: "consent-input-3",
+ headline: "Terms and Conditions",
+ checkboxLabel: "I agree to the terms and conditions",
+ value: true,
+ onChange: () => {},
+ },
+};
+
+export const Required: Story = {
+ args: {
+ elementId: "consent-4",
+ inputId: "consent-input-4",
+ headline: "Terms and Conditions",
+ checkboxLabel: "I agree to the terms and conditions",
+ required: true,
+ onChange: () => {},
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ elementId: "consent-5",
+ inputId: "consent-input-5",
+ headline: "Terms and Conditions",
+ checkboxLabel: "I agree to the terms and conditions",
+ required: true,
+ errorMessage: "You must accept the terms to continue",
+ onChange: () => {},
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ elementId: "consent-6",
+ inputId: "consent-input-6",
+ headline: "Terms and Conditions",
+ checkboxLabel: "I agree to the terms and conditions",
+ value: true,
+ disabled: true,
+ onChange: () => {},
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ elementId: "consent-rtl",
+ inputId: "consent-input-rtl",
+ headline: "الشروط والأحكام",
+ description: "يرجى قراءة الشروط والموافقة عليها",
+ checkboxLabel: "أوافق على الشروط والأحكام",
+ dir: "rtl",
+ onChange: () => {},
+ },
+};
+
+export const RTLWithConsent: Story = {
+ args: {
+ elementId: "consent-rtl-checked",
+ inputId: "consent-input-rtl-checked",
+ headline: "الشروط والأحكام",
+ checkboxLabel: "أوافق على الشروط والأحكام",
+ value: true,
+ dir: "rtl",
+ onChange: () => {},
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/consent.tsx b/packages/survey-ui/src/components/elements/consent.tsx
new file mode 100644
index 0000000000..7cf7bab4f8
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/consent.tsx
@@ -0,0 +1,92 @@
+import * as React from "react";
+import { Checkbox } from "@/components/general/checkbox";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { cn } from "@/lib/utils";
+
+/**
+ * Props for the Consent element component
+ */
+export interface ConsentProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the consent checkbox */
+ inputId: string;
+ /** Label text for the consent checkbox */
+ checkboxLabel: string;
+ /** Whether consent is checked */
+ value?: boolean;
+ /** Callback function called when consent changes */
+ onChange: (checked: boolean) => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the checkbox is disabled */
+ disabled?: boolean;
+}
+
+function Consent({
+ elementId,
+ headline,
+ description,
+ inputId,
+ checkboxLabel,
+ value = false,
+ onChange,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+}: Readonly): React.JSX.Element {
+ const handleCheckboxChange = (checked: boolean): void => {
+ if (disabled) return;
+ onChange(checked);
+ };
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Consent Checkbox */}
+
+
+
+
+
+ {/* need to use style here because tailwind is not able to use css variables for font size and weight */}
+
+ {checkboxLabel}
+
+
+
+
+ );
+}
+
+export { Consent };
diff --git a/packages/survey-ui/src/components/elements/cta.stories.tsx b/packages/survey-ui/src/components/elements/cta.stories.tsx
new file mode 100644
index 0000000000..874601d034
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/cta.stories.tsx
@@ -0,0 +1,186 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ type BaseStylingOptions,
+ type ButtonStylingOptions,
+ buttonStylingArgTypes,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ elementStylingArgTypes,
+} from "../../lib/story-helpers";
+import { CTA, type CTAProps } from "./cta";
+
+type StoryProps = CTAProps & Partial & Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/CTA",
+ component: CTA,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A Call-to-Action (CTA) element that displays a button. Can optionally open an external URL when clicked.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ buttonLabel: {
+ control: "text",
+ description: "Label text for the CTA button",
+ table: { category: "Content" },
+ },
+ buttonUrl: {
+ control: "text",
+ description: "URL to open when button is clicked (if external)",
+ table: { category: "Content" },
+ },
+ buttonExternal: {
+ control: "boolean",
+ description: "Whether the button opens an external URL",
+ table: { category: "Content" },
+ },
+ buttonVariant: {
+ control: "select",
+ options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"],
+ description: "Variant for the button. Must be 'custom' for button styling controls to work.",
+ table: { category: "Button Styling (Only applicable when buttonVariant is 'custom')" },
+ },
+ onClick: {
+ action: () => {
+ alert("clicked");
+ },
+ table: { category: "Events" },
+ },
+ ...elementStylingArgTypes,
+ ...buttonStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ elementId: "cta-1",
+ inputId: "cta-input-1",
+ headline: "Ready to get started?",
+ buttonLabel: "Get Started",
+ onClick: () => {
+ alert("clicked");
+ },
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ elementId: "cta-2",
+ inputId: "cta-input-2",
+ headline: "Ready to get started?",
+ description: "Click the button below to begin your journey",
+ buttonLabel: "Get Started",
+ onClick: () => {
+ alert("clicked");
+ },
+ },
+};
+
+export const ExternalButton: Story = {
+ args: {
+ elementId: "cta-3",
+ inputId: "cta-input-3",
+ headline: "Learn more about us",
+ description: "Visit our website to learn more",
+ buttonLabel: "Visit Website",
+ buttonUrl: "https://example.com",
+ buttonExternal: true,
+ onClick: () => {
+ alert("clicked");
+ },
+ },
+};
+
+export const Required: Story = {
+ args: {
+ elementId: "cta-4",
+ inputId: "cta-input-4",
+ headline: "Ready to get started?",
+ buttonLabel: "Get Started",
+ required: true,
+ onClick: () => {
+ alert("clicked");
+ },
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ elementId: "cta-5",
+ inputId: "cta-input-5",
+ headline: "Ready to get started?",
+ buttonLabel: "Get Started",
+ required: true,
+ errorMessage: "Please click the button to continue",
+ onClick: () => {
+ alert("clicked");
+ },
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ elementId: "cta-6",
+ inputId: "cta-input-6",
+ headline: "Ready to get started?",
+ buttonLabel: "Get Started",
+ disabled: true,
+ onClick: () => {
+ alert("clicked");
+ },
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ elementId: "cta-rtl",
+ inputId: "cta-input-rtl",
+ headline: "هل أنت مستعد للبدء؟",
+ description: "انقر على الزر أدناه للبدء",
+ buttonLabel: "ابدأ الآن",
+ dir: "rtl",
+ onClick: () => {
+ alert("clicked");
+ },
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {
+ alert("clicked");
+ }}
+ />
+ {
+ alert("clicked");
+ }}
+ />
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/cta.tsx b/packages/survey-ui/src/components/elements/cta.tsx
new file mode 100644
index 0000000000..46bcc8df74
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/cta.tsx
@@ -0,0 +1,89 @@
+import { SquareArrowOutUpRightIcon } from "lucide-react";
+import * as React from "react";
+import { Button } from "@/components/general/button";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+
+/**
+ * Props for the CTA (Call to Action) element component
+ */
+export interface CTAProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the CTA button */
+ inputId: string;
+ /** Label text for the CTA button */
+ buttonLabel: string;
+ /** URL to open when button is clicked (if external button) */
+ buttonUrl?: string;
+ /** Whether the button opens an external URL */
+ buttonExternal?: boolean;
+ /** Callback function called when button is clicked */
+ onClick: () => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the button is disabled */
+ disabled?: boolean;
+ /** Variant for the button */
+ buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
+}
+
+function CTA({
+ elementId,
+ headline,
+ description,
+ inputId,
+ buttonLabel,
+ buttonUrl,
+ buttonExternal = false,
+ onClick,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+ buttonVariant = "default",
+}: Readonly): React.JSX.Element {
+ const handleButtonClick = (): void => {
+ if (disabled) return;
+ onClick();
+
+ if (buttonExternal && buttonUrl) {
+ window.open(buttonUrl, "_blank")?.focus();
+ }
+ };
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* CTA Button */}
+
+
+
+
+
+ {buttonLabel}
+ {buttonExternal ? : null}
+
+
+
+
+ );
+}
+
+export { CTA };
diff --git a/packages/survey-ui/src/components/elements/date.stories.tsx b/packages/survey-ui/src/components/elements/date.stories.tsx
new file mode 100644
index 0000000000..1392629b22
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/date.stories.tsx
@@ -0,0 +1,315 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ type BaseStylingOptions,
+ type InputLayoutStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+ elementStylingArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { DateElement, type DateElementProps } from "./date";
+
+type StoryProps = DateElementProps &
+ Partial> &
+ Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/Date",
+ component: DateElement,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A complete date element that combines headline, description, and a date input. Supports date range constraints, validation, and RTL text direction.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ value: {
+ control: "text",
+ description: "Current date value in ISO format (YYYY-MM-DD)",
+ table: { category: "State" },
+ },
+ minDate: {
+ control: "text",
+ description: "Minimum date allowed (ISO format: YYYY-MM-DD)",
+ table: { category: "Validation" },
+ },
+ maxDate: {
+ control: "text",
+ description: "Maximum date allowed (ISO format: YYYY-MM-DD)",
+ table: { category: "Validation" },
+ },
+ locale: {
+ control: { type: "select" },
+ options: [
+ "en",
+ "de",
+ "fr",
+ "es",
+ "ja",
+ "pt",
+ "pt-BR",
+ "ro",
+ "zh-Hans",
+ "zh-Hant",
+ "nl",
+ "ar",
+ "it",
+ "ru",
+ "uz",
+ "hi",
+ ],
+ description: "Locale code for date formatting (survey language codes: 'en', 'de', 'ar', etc.)",
+ table: { category: "Localization" },
+ },
+ },
+ render: createStatefulRender(DateElement),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const StylingPlayground: Story = {
+ args: {
+ headline: "What is your date of birth?",
+ description: "Please select a date",
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...surveyStylingArgTypes,
+ inputBgColor: {
+ control: "color",
+ table: { category: "Input Styling" },
+ },
+ inputBorderColor: {
+ control: "color",
+ table: { category: "Input Styling" },
+ },
+ inputColor: {
+ control: "color",
+ table: { category: "Input Styling" },
+ },
+ inputBorderRadius: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ headline: "What is your date of birth?",
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "When would you like to schedule the appointment?",
+ description: "Please select a date for your appointment",
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "What is your date of birth?",
+ description: "Please select your date of birth",
+ required: true,
+ },
+};
+
+export const WithValue: Story = {
+ args: {
+ headline: "What is your date of birth?",
+ value: "1990-01-15",
+ },
+};
+
+export const WithDateRange: Story = {
+ args: {
+ headline: "Select a date for your event",
+ description: "Please choose a date between today and next year",
+ minDate: new Date().toISOString().split("T")[0],
+ maxDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split("T")[0],
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ headline: "What is your date of birth?",
+ description: "Please select your date of birth",
+ errorMessage: "Please select a valid date",
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ headline: "This date field is disabled",
+ description: "You cannot change the date",
+ value: "2024-01-15",
+ disabled: true,
+ },
+};
+
+export const PastDatesOnly: Story = {
+ args: {
+ headline: "When did you start your current job?",
+ description: "Select a date in the past",
+ maxDate: new Date().toISOString().split("T")[0],
+ },
+};
+
+export const FutureDatesOnly: Story = {
+ args: {
+ headline: "When would you like to schedule the meeting?",
+ description: "Select a date in the future",
+ minDate: new Date().toISOString().split("T")[0],
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ headline: "ما هو تاريخ ميلادك؟",
+ description: "يرجى اختيار تاريخ",
+ dir: "rtl",
+ },
+};
+
+export const RTLWithValue: Story = {
+ args: {
+ headline: "ما هو تاريخ ميلادك؟",
+ description: "يرجى اختيار تاريخ",
+ value: "1990-01-15",
+ dir: "rtl",
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
+
+export const WithLocale: Story = {
+ args: {
+ headline: "What is your date of birth?",
+ description: "Date picker with locale-specific formatting",
+ locale: "en",
+ },
+};
+
+export const LocaleExamples: Story = {
+ render: () => (
+
+
+
English (en)
+ {}}
+ />
+
+
+
German (de)
+ {}}
+ />
+
+
+
French (fr)
+ {}}
+ />
+
+
+
Spanish (es)
+ {}}
+ />
+
+
+
Japanese (ja)
+ {}}
+ />
+
+
+
Arabic (ar)
+ {}}
+ />
+
+
+
Russian (ru)
+ {}}
+ />
+
+
+
Chinese Simplified (zh-Hans)
+ {}}
+ />
+
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/date.tsx b/packages/survey-ui/src/components/elements/date.tsx
new file mode 100644
index 0000000000..d00c2e6e90
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/date.tsx
@@ -0,0 +1,148 @@
+import * as React from "react";
+import { Calendar } from "@/components/general/calendar";
+import { ElementHeader } from "@/components/general/element-header";
+import { getDateFnsLocale } from "@/lib/locale";
+
+interface DateElementProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the date input */
+ inputId: string;
+ /** Current date value in ISO format (YYYY-MM-DD) */
+ value?: string;
+ /** Callback function called when the date value changes */
+ onChange: (value: string) => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Minimum date allowed (ISO format: YYYY-MM-DD) */
+ minDate?: string;
+ /** Maximum date allowed (ISO format: YYYY-MM-DD) */
+ maxDate?: string;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the date input is disabled */
+ disabled?: boolean;
+ /** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */
+ locale?: string;
+}
+
+function DateElement({
+ elementId,
+ headline,
+ description,
+ inputId,
+ value,
+ onChange,
+ required = false,
+ minDate,
+ maxDate,
+ dir = "auto",
+ disabled = false,
+ locale = "en-US",
+}: Readonly): React.JSX.Element {
+ // Initialize date from value string, parsing as local time to avoid timezone issues
+ const [date, setDate] = React.useState(() => {
+ if (!value) return undefined;
+ // Parse YYYY-MM-DD format as local date (not UTC)
+ const [year, month, day] = value.split("-").map(Number);
+ return new Date(year, month - 1, day);
+ });
+
+ // Sync date state when value prop changes
+ React.useEffect(() => {
+ if (value) {
+ // Parse YYYY-MM-DD format as local date (not UTC)
+ const [year, month, day] = value.split("-").map(Number);
+ const newDate = new Date(year, month - 1, day);
+ setDate((prevDate) => {
+ // Only update if the date actually changed to avoid unnecessary re-renders
+ if (!prevDate || newDate.getTime() !== prevDate.getTime()) {
+ return newDate;
+ }
+ return prevDate;
+ });
+ } else {
+ setDate(undefined);
+ }
+ }, [value]);
+
+ // Convert Date to ISO string (YYYY-MM-DD) when date changes
+ const handleDateSelect = (selectedDate: Date | undefined): void => {
+ setDate(selectedDate);
+ if (selectedDate) {
+ // Convert to ISO format (YYYY-MM-DD) using local time to avoid timezone issues
+ const year = String(selectedDate.getFullYear());
+ const month = String(selectedDate.getMonth() + 1).padStart(2, "0");
+ const day = String(selectedDate.getDate()).padStart(2, "0");
+ const isoString = `${year}-${month}-${day}`;
+ onChange(isoString);
+ } else {
+ onChange("");
+ }
+ };
+
+ // Convert minDate/maxDate strings to Date objects
+ const minDateObj = minDate ? new Date(minDate) : undefined;
+ const maxDateObj = maxDate ? new Date(maxDate) : undefined;
+
+ // Create disabled function for date restrictions
+ const isDateDisabled = React.useCallback(
+ (dateToCheck: Date): boolean => {
+ if (disabled) return true;
+ if (minDateObj) {
+ const minAtMidnight = new Date(minDateObj.getFullYear(), minDateObj.getMonth(), minDateObj.getDate());
+ const checkAtMidnight = new Date(
+ dateToCheck.getFullYear(),
+ dateToCheck.getMonth(),
+ dateToCheck.getDate()
+ );
+ if (checkAtMidnight < minAtMidnight) return true;
+ }
+ if (maxDateObj) {
+ const maxAtMidnight = new Date(maxDateObj.getFullYear(), maxDateObj.getMonth(), maxDateObj.getDate());
+ const checkAtMidnight = new Date(
+ dateToCheck.getFullYear(),
+ dateToCheck.getMonth(),
+ dateToCheck.getDate()
+ );
+ if (checkAtMidnight > maxAtMidnight) return true;
+ }
+ return false;
+ },
+ [disabled, minDateObj, maxDateObj]
+ );
+
+ // Get locale for date formatting
+ const dateLocale = React.useMemo(() => {
+ return locale ? getDateFnsLocale(locale) : undefined;
+ }, [locale]);
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Calendar - Always visible */}
+
+
+
+
+ );
+}
+
+export { DateElement };
+export type { DateElementProps };
diff --git a/packages/survey-ui/src/components/elements/file-upload.stories.tsx b/packages/survey-ui/src/components/elements/file-upload.stories.tsx
new file mode 100644
index 0000000000..9658b404b0
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/file-upload.stories.tsx
@@ -0,0 +1,244 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { useEffect, useState } from "react";
+import {
+ type BaseStylingOptions,
+ type InputLayoutStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ elementStylingArgTypes,
+ inputStylingArgTypes,
+ pickArgTypes,
+} from "../../lib/story-helpers";
+import { FileUpload, type FileUploadProps, type UploadedFile } from "./file-upload";
+
+type StoryProps = FileUploadProps &
+ Partial &
+ Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/FileUpload",
+ component: FileUpload,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A complete file upload element that combines headline, description, and a file upload area with drag-and-drop support. Supports file type restrictions, size limits, multiple files, validation, and RTL text direction.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ decorators: [createCSSVariablesDecorator()],
+ argTypes: {
+ ...commonArgTypes,
+ value: {
+ control: "object",
+ description: "Array of uploaded files",
+ table: { category: "State" },
+ },
+ allowMultiple: {
+ control: "boolean",
+ description: "Whether multiple files are allowed",
+ table: { category: "Behavior" },
+ },
+ maxSizeInMB: {
+ control: "number",
+ description: "Maximum file size in MB",
+ table: { category: "Validation" },
+ },
+ allowedFileExtensions: {
+ control: "object",
+ description: "Allowed file extensions (e.g., ['.pdf', '.jpg'])",
+ table: { category: "Validation" },
+ },
+ ...elementStylingArgTypes,
+ ...pickArgTypes(inputStylingArgTypes, [
+ "inputBgColor",
+ "inputBorderColor",
+ "inputColor",
+ "inputFontSize",
+ "inputFontWeight",
+ "inputWidth",
+ "inputHeight",
+ "inputBorderRadius",
+ "inputPaddingX",
+ "inputPaddingY",
+ ]),
+ },
+ render: function Render(args: StoryProps) {
+ const [value, setValue] = useState(args.value);
+
+ useEffect(() => {
+ setValue(args.value);
+ }, [args.value]);
+
+ return (
+ {
+ setValue(v);
+ args.onChange?.(v);
+ }}
+ />
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ headline: "Upload your file",
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "Upload your resume",
+ description: "Please upload your resume in PDF format",
+ },
+};
+
+export const SingleFile: Story = {
+ args: {
+ headline: "Upload a single file",
+ description: "Select one file to upload",
+ allowMultiple: false,
+ },
+};
+
+export const MultipleFiles: Story = {
+ args: {
+ headline: "Upload multiple files",
+ description: "You can upload multiple files at once",
+ allowMultiple: true,
+ },
+};
+
+export const WithFileTypeRestrictions: Story = {
+ args: {
+ headline: "Upload an image",
+ description: "Please upload an image file",
+ allowedFileExtensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
+ },
+};
+
+export const WithSizeLimit: Story = {
+ args: {
+ headline: "Upload a document",
+ description: "Maximum file size: 5MB",
+ maxSizeInMB: 5,
+ },
+};
+
+export const WithRestrictions: Story = {
+ args: {
+ headline: "Upload a PDF document",
+ description: "PDF files only, maximum 10MB",
+ allowedFileExtensions: [".pdf"],
+ maxSizeInMB: 10,
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "Upload required file",
+ description: "Please upload a file",
+ required: true,
+ },
+};
+
+export const WithUploadedFiles: Story = {
+ args: {
+ headline: "Upload your files",
+ description: "Files you've uploaded",
+ allowMultiple: true,
+ value: [
+ {
+ name: "document.pdf",
+ url: "data:application/pdf;base64,...",
+ size: 1024 * 500, // 500 KB
+ },
+ {
+ name: "image.jpg",
+ url: "data:image/jpeg;base64,...",
+ size: 1024 * 1024 * 2, // 2 MB
+ },
+ ] as UploadedFile[],
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ headline: "Upload your file",
+ description: "Please upload a file",
+ errorMessage: "Please upload at least one file",
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ headline: "This upload is disabled",
+ description: "You cannot upload files",
+ value: [
+ {
+ name: "existing-file.pdf",
+ url: "data:application/pdf;base64,...",
+ size: 1024 * 300,
+ },
+ ] as UploadedFile[],
+ disabled: true,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ headline: "قم بتحميل ملفك",
+ description: "يرجى اختيار ملف للتحميل",
+ dir: "rtl",
+ },
+};
+
+export const RTLWithFiles: Story = {
+ args: {
+ headline: "قم بتحميل ملفاتك",
+ description: "الملفات التي قمت بتحميلها",
+ allowMultiple: true,
+ value: [
+ {
+ name: "ملف.pdf",
+ url: "data:application/pdf;base64,...",
+ size: 1024 * 500,
+ },
+ ] as UploadedFile[],
+ dir: "rtl",
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/file-upload.tsx b/packages/survey-ui/src/components/elements/file-upload.tsx
new file mode 100644
index 0000000000..2674b7c382
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/file-upload.tsx
@@ -0,0 +1,336 @@
+import { Upload, UploadIcon, X } from "lucide-react";
+import * as React from "react";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { Input } from "@/components/general/input";
+import { cn } from "@/lib/utils";
+
+/**
+ * Uploaded file information
+ */
+export interface UploadedFile {
+ /** File name */
+ name: string;
+ /** File URL or data URL */
+ url: string;
+ /** File size in bytes */
+ size?: number;
+}
+
+interface FileUploadProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the file input */
+ inputId: string;
+ /** Currently uploaded files */
+ value?: UploadedFile[];
+ /** Callback function called when files change */
+ onChange: (files: UploadedFile[]) => void;
+ /** Callback function called when files are selected (before validation) */
+ onFileSelect?: (files: FileList) => void;
+ /** Whether multiple files are allowed */
+ allowMultiple?: boolean;
+ /** Allowed file extensions (e.g., ['.pdf', '.jpg', '.png']) */
+ allowedFileExtensions?: string[];
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Whether the component is in uploading state */
+ isUploading?: boolean;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the file input is disabled */
+ disabled?: boolean;
+ /** Image URL to display above the headline */
+ imageUrl?: string;
+ /** Video URL to display above the headline */
+ videoUrl?: string;
+ /** Alt text for the image */
+ imageAltText?: string;
+ /** Placeholder text for the file upload */
+ placeholderText?: string;
+}
+
+interface UploadedFileItemProps {
+ file: UploadedFile;
+ index: number;
+ disabled: boolean;
+ onDelete: (index: number, e: React.MouseEvent) => void;
+}
+
+function UploadedFileItem({
+ file,
+ index,
+ disabled,
+ onDelete,
+}: Readonly): React.JSX.Element {
+ return (
+
+
+ {
+ onDelete(index, e);
+ }}
+ disabled={disabled}
+ className={cn(
+ "flex h-5 w-5 cursor-pointer items-center justify-center rounded-md",
+ "bg-background hover:bg-accent",
+ disabled && "cursor-not-allowed opacity-50"
+ )}
+ aria-label={`Delete ${file.name}`}>
+
+
+
+
+
+ );
+}
+
+interface UploadedFilesListProps {
+ files: UploadedFile[];
+ disabled: boolean;
+ onDelete: (index: number, e: React.MouseEvent) => void;
+}
+
+function UploadedFilesList({
+ files,
+ disabled,
+ onDelete,
+}: Readonly): React.JSX.Element | null {
+ if (files.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {files.map((file, index) => (
+
+ ))}
+
+ );
+}
+
+interface UploadAreaProps {
+ inputId: string;
+ fileInputRef: React.RefObject;
+ placeholderText: string;
+ allowMultiple: boolean;
+ acceptAttribute?: string;
+ required: boolean;
+ disabled: boolean;
+ dir: "ltr" | "rtl" | "auto";
+ onFileChange: (e: React.ChangeEvent) => void;
+ onDragOver: (e: React.DragEvent) => void;
+ onDrop: (e: React.DragEvent) => void;
+ showUploader: boolean;
+}
+
+function UploadArea({
+ inputId,
+ fileInputRef,
+ placeholderText,
+ allowMultiple,
+ acceptAttribute,
+ required,
+ disabled,
+ dir,
+ onFileChange,
+ onDragOver,
+ onDrop,
+ showUploader,
+}: Readonly): React.JSX.Element | null {
+ if (!showUploader) {
+ return null;
+ }
+
+ return (
+
+ {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ }}
+ disabled={disabled}
+ className={cn(
+ "flex w-full flex-col items-center justify-center py-6",
+ "hover:cursor-pointer",
+ disabled && "cursor-not-allowed opacity-50"
+ )}
+ aria-label="Upload files by clicking or dragging them here">
+
+
+ {placeholderText}
+
+
+
+
+ );
+}
+
+function FileUpload({
+ elementId,
+ headline,
+ description,
+ inputId,
+ value = [],
+ onChange,
+ onFileSelect,
+ allowMultiple = false,
+ allowedFileExtensions,
+ required = false,
+ errorMessage,
+ isUploading = false,
+ dir = "auto",
+ disabled = false,
+ imageUrl,
+ videoUrl,
+ imageAltText,
+ placeholderText = "Click or drag to upload files",
+}: Readonly): React.JSX.Element {
+ const fileInputRef = React.useRef(null);
+
+ // Ensure value is always an array
+ const uploadedFiles = Array.isArray(value) ? value : [];
+
+ const handleFileChange = (e: React.ChangeEvent): void => {
+ if (!e.target.files || disabled) return;
+ if (onFileSelect) {
+ onFileSelect(e.target.files);
+ }
+ // Reset input to allow selecting the same file again
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent): void => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = "copy";
+ };
+
+ const handleDrop = (e: React.DragEvent): void => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (onFileSelect && e.dataTransfer.files.length > 0) {
+ onFileSelect(e.dataTransfer.files);
+ }
+ };
+
+ const handleDeleteFile = (index: number, e: React.MouseEvent): void => {
+ e.stopPropagation();
+ const updatedFiles = [...uploadedFiles];
+ updatedFiles.splice(index, 1);
+ onChange(updatedFiles);
+ };
+
+ // Build accept attribute from allowed extensions
+ const acceptAttribute = allowedFileExtensions
+ ?.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
+ .join(",");
+
+ // Show uploader if uploading, or if multiple files allowed, or if no files uploaded yet
+ const showUploader = isUploading || allowMultiple || uploadedFiles.length === 0;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {isUploading ? (
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
+
+export { FileUpload };
+export type { FileUploadProps };
diff --git a/packages/survey-ui/src/components/elements/form-field.stories.tsx b/packages/survey-ui/src/components/elements/form-field.stories.tsx
new file mode 100644
index 0000000000..d28f455658
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/form-field.stories.tsx
@@ -0,0 +1,362 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ type BaseStylingOptions,
+ type InputLayoutStylingOptions,
+ type LabelStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+} from "../../lib/story-helpers";
+import { FormField, type FormFieldConfig, type FormFieldProps } from "./form-field";
+
+type StoryProps = FormFieldProps &
+ Partial &
+ Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/FormField",
+ component: FormField,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A flexible form field element that can display multiple input fields with different configurations. Replaces Contact Info and Address elements.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ fields: {
+ control: "object",
+ description: "Array of form field configurations",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "object",
+ description: "Current values as a record mapping field IDs to their values",
+ table: { category: "State" },
+ },
+ },
+ render: createStatefulRender(FormField),
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+
+// Contact Info fields preset
+const contactInfoFields: FormFieldConfig[] = [
+ { id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true },
+ { id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true },
+ { id: "email", label: "Email", placeholder: "Email", type: "email", required: true, show: true },
+ { id: "phone", label: "Phone", placeholder: "Phone", type: "tel", required: true, show: true },
+ { id: "company", label: "Company", placeholder: "Company", required: true, show: true },
+];
+
+// Address fields preset
+const addressFields: FormFieldConfig[] = [
+ { id: "addressLine1", label: "Address Line 1", placeholder: "Address Line 1", required: true, show: true },
+ { id: "addressLine2", label: "Address Line 2", placeholder: "Address Line 2", required: true, show: true },
+ { id: "city", label: "City", placeholder: "City", required: true, show: true },
+ { id: "state", label: "State", placeholder: "State", required: true, show: true },
+ { id: "zip", label: "Zip", placeholder: "Zip", required: true, show: true },
+ { id: "country", label: "Country", placeholder: "Country", required: true, show: true },
+];
+
+export const StylingPlayground: Story = {
+ args: {
+ elementId: "form-field-1",
+ headline: "Please provide your contact information",
+ description: "We'll use this to contact you",
+ fields: contactInfoFields,
+ },
+ argTypes: {
+ elementHeadlineFontFamily: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementHeadlineFontSize: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementHeadlineFontWeight: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementHeadlineColor: {
+ control: "color",
+ table: { category: "Element Styling" },
+ },
+ elementDescriptionFontFamily: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementDescriptionFontSize: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementDescriptionFontWeight: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementDescriptionColor: {
+ control: "color",
+ table: { category: "Element Styling" },
+ },
+ labelFontFamily: {
+ control: "text",
+ table: { category: "Label Styling" },
+ },
+ labelFontSize: {
+ control: "text",
+ table: { category: "Label Styling" },
+ },
+ labelFontWeight: {
+ control: "text",
+ table: { category: "Label Styling" },
+ },
+ labelColor: {
+ control: "color",
+ table: { category: "Label Styling" },
+ },
+ inputWidth: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ inputHeight: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ inputBgColor: {
+ control: "color",
+ table: { category: "Input Styling" },
+ },
+ inputBorderColor: {
+ control: "color",
+ table: { category: "Input Styling" },
+ },
+ inputBorderRadius: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ inputFontFamily: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ inputFontSize: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ inputFontWeight: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ inputColor: {
+ control: "color",
+ table: { category: "Input Styling" },
+ },
+ inputPaddingX: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ inputPaddingY: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ inputShadow: {
+ control: "text",
+ table: { category: "Input Styling" },
+ },
+ brandColor: {
+ control: "color",
+ table: { category: "Survey Styling" },
+ },
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ elementId: "form-field-1",
+ headline: "Please provide your contact information",
+ fields: contactInfoFields,
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ elementId: "form-field-2",
+ headline: "Please provide your contact information",
+ description: "We'll use this to contact you about your inquiry",
+ fields: contactInfoFields,
+ },
+};
+
+export const ContactInfo: Story = {
+ args: {
+ elementId: "form-field-contact",
+ headline: "Contact Information",
+ description: "Please provide your contact details",
+ fields: contactInfoFields,
+ },
+};
+
+export const Address: Story = {
+ args: {
+ elementId: "form-field-address",
+ headline: "Shipping Address",
+ description: "Please provide your shipping address",
+ fields: addressFields,
+ },
+};
+
+export const Required: Story = {
+ args: {
+ elementId: "form-field-3",
+ headline: "Please provide your contact information",
+ fields: contactInfoFields,
+ required: true,
+ },
+};
+
+export const WithValues: Story = {
+ args: {
+ elementId: "form-field-4",
+ headline: "Please provide your contact information",
+ fields: contactInfoFields,
+ value: {
+ firstName: "John",
+ lastName: "Doe",
+ email: "john.doe@example.com",
+ phone: "+1234567890",
+ company: "Acme Inc.",
+ },
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ elementId: "form-field-5",
+ headline: "Please provide your contact information",
+ fields: contactInfoFields,
+ required: true,
+ errorMessage: "Please fill in all required fields",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ elementId: "form-field-6",
+ headline: "Please provide your contact information",
+ fields: contactInfoFields,
+ disabled: true,
+ value: {
+ firstName: "John",
+ lastName: "Doe",
+ email: "john.doe@example.com",
+ },
+ },
+};
+
+export const PartialFields: Story = {
+ args: {
+ elementId: "form-field-7",
+ headline: "Basic Information",
+ fields: [
+ { id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true },
+ { id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true },
+ { id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true },
+ {
+ id: "phone",
+ label: "Phone",
+ placeholder: "Phone (optional)",
+ type: "tel",
+ required: false,
+ show: false,
+ },
+ ],
+ },
+};
+
+export const OptionalFields: Story = {
+ args: {
+ elementId: "form-field-8",
+ headline: "Optional Information",
+ fields: [
+ { id: "firstName", label: "First Name", placeholder: "First Name", required: false, show: true },
+ { id: "lastName", label: "Last Name", placeholder: "Last Name", required: false, show: true },
+ { id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true },
+ ],
+ required: false,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ elementId: "form-field-rtl",
+ headline: "يرجى تقديم معلومات الاتصال الخاصة بك",
+ description: "سنستخدم هذا للاتصال بك",
+ dir: "rtl",
+ fields: [
+ { id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true },
+ { id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true },
+ {
+ id: "email",
+ label: "البريد الإلكتروني",
+ placeholder: "البريد الإلكتروني",
+ type: "email",
+ required: true,
+ show: true,
+ },
+ ],
+ },
+};
+
+export const RTLWithValues: Story = {
+ args: {
+ elementId: "form-field-rtl-values",
+ dir: "rtl",
+ headline: "يرجى تقديم معلومات الاتصال الخاصة بك",
+ fields: [
+ { id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true },
+ { id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true },
+ {
+ id: "email",
+ label: "البريد الإلكتروني",
+ placeholder: "البريد الإلكتروني",
+ type: "email",
+ required: true,
+ show: true,
+ },
+ ],
+ value: {
+ firstName: "أحمد",
+ lastName: "محمد",
+ email: "ahmed@example.com",
+ },
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/form-field.tsx b/packages/survey-ui/src/components/elements/form-field.tsx
new file mode 100644
index 0000000000..ad37f47543
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/form-field.tsx
@@ -0,0 +1,140 @@
+import * as React from "react";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { Input } from "@/components/general/input";
+import { Label } from "@/components/general/label";
+
+/**
+ * Form field configuration
+ */
+export interface FormFieldConfig {
+ /** Unique identifier for the field */
+ id: string;
+ /** Label text for the field */
+ label: string;
+ /** Placeholder text for the input */
+ placeholder?: string;
+ /** Input type (text, email, tel, number, url, etc.) */
+ type?: "text" | "email" | "tel" | "number" | "url";
+ /** Whether this field is required */
+ required?: boolean;
+ /** Whether this field should be shown */
+ show?: boolean;
+}
+
+interface FormFieldProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Array of form field configurations */
+ fields: FormFieldConfig[];
+ /** Current values as a record mapping field IDs to their values */
+ value?: Record;
+ /** Callback function called when any field value changes */
+ onChange: (value: Record) => void;
+ /** Whether the entire form is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the controls are disabled */
+ disabled?: boolean;
+}
+
+function FormField({
+ elementId,
+ headline,
+ description,
+ fields,
+ value = {},
+ onChange,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+}: Readonly): React.JSX.Element {
+ // Ensure value is always an object
+ const currentValues = React.useMemo(() => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- value can be undefined
+ return value ?? {};
+ }, [value]);
+
+ // Determine if a field is required
+ const isFieldRequired = (field: FormFieldConfig): boolean => {
+ if (field.required) {
+ return true;
+ }
+
+ // If all fields are optional and the form is required, then fields should be required
+ const visibleFields = fields.filter((f) => f.show !== false);
+ const allOptional = visibleFields.every((f) => !f.required);
+ if (allOptional && required) {
+ return true;
+ }
+
+ return false;
+ };
+
+ // Handle field value change
+ const handleFieldChange = (fieldId: string, fieldValue: string): void => {
+ onChange({
+ ...currentValues,
+ [fieldId]: fieldValue,
+ });
+ };
+
+ // Get visible fields
+ const visibleFields = fields.filter((field) => field.show !== false);
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Form Fields */}
+
+
+ {visibleFields.map((field) => {
+ const fieldRequired = isFieldRequired(field);
+ const fieldValue = currentValues[field.id] ?? "";
+ const fieldInputId = `${elementId}-${field.id}`;
+
+ // Determine input type
+ let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text";
+ if (field.id === "email" && !field.type) {
+ inputType = "email";
+ } else if (field.id === "phone" && !field.type) {
+ inputType = "tel";
+ }
+
+ return (
+
+
+ {fieldRequired ? `${field.label}*` : field.label}
+
+ {
+ handleFieldChange(field.id, e.target.value);
+ }}
+ required={fieldRequired}
+ disabled={disabled}
+ dir={dir}
+ aria-invalid={Boolean(errorMessage) || undefined}
+ />
+
+ );
+ })}
+
+
+ );
+}
+
+export { FormField };
+export type { FormFieldProps };
diff --git a/packages/survey-ui/src/components/elements/matrix.stories.tsx b/packages/survey-ui/src/components/elements/matrix.stories.tsx
new file mode 100644
index 0000000000..30925d1be8
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/matrix.stories.tsx
@@ -0,0 +1,307 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ type BaseStylingOptions,
+ type InputLayoutStylingOptions,
+ type LabelStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+ elementStylingArgTypes,
+ inputStylingArgTypes,
+ labelStylingArgTypes,
+ pickArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { Matrix, type MatrixOption, type MatrixProps } from "./matrix";
+
+type StoryProps = MatrixProps &
+ Partial &
+ Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/Matrix",
+ component: Matrix,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A complete matrix element that combines headline, description, and a table with rows and columns. Each row can have one selected column value. Supports validation and RTL text direction.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ rows: {
+ control: "object",
+ description: "Array of row options (left side)",
+ table: { category: "Content" },
+ },
+ columns: {
+ control: "object",
+ description: "Array of column options (top header)",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "object",
+ description: "Record mapping row ID to column ID",
+ table: { category: "State" },
+ },
+ },
+ render: createStatefulRender(Matrix),
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+
+const defaultRows: MatrixOption[] = [
+ { id: "row-1", label: "Row 1" },
+ { id: "row-2", label: "Row 2" },
+ { id: "row-3", label: "Row 3" },
+];
+
+const defaultColumns: MatrixOption[] = [
+ { id: "col-1", label: "Column 1" },
+ { id: "col-2", label: "Column 2" },
+ { id: "col-3", label: "Column 3" },
+ { id: "col-4", label: "Column 4" },
+];
+
+export const StylingPlayground: Story = {
+ args: {
+ headline: "Rate each item",
+ description: "Select a value for each row",
+ rows: defaultRows,
+ columns: defaultColumns,
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...labelStylingArgTypes,
+ ...pickArgTypes(inputStylingArgTypes, ["inputBgColor", "inputBorderColor"]),
+ ...surveyStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ elementId: "matrix-default",
+ inputId: "matrix-default-input",
+ headline: "Rate each item",
+ rows: defaultRows,
+ columns: defaultColumns,
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ elementId: "matrix-with-description",
+ inputId: "matrix-with-description-input",
+ headline: "How satisfied are you with each feature?",
+ description: "Please rate each feature on a scale from 1 to 5",
+ rows: [
+ { id: "feature-1", label: "Feature 1" },
+ { id: "feature-2", label: "Feature 2" },
+ { id: "feature-3", label: "Feature 3" },
+ ],
+ columns: [
+ { id: "1", label: "1" },
+ { id: "2", label: "2" },
+ { id: "3", label: "3" },
+ { id: "4", label: "4" },
+ { id: "5", label: "5" },
+ ],
+ },
+};
+
+export const Required: Story = {
+ args: {
+ elementId: "matrix-required",
+ inputId: "matrix-required-input",
+ headline: "Rate each item",
+ description: "Please select a value for each row",
+ rows: defaultRows,
+ columns: defaultColumns,
+ required: true,
+ },
+};
+
+export const WithSelections: Story = {
+ args: {
+ elementId: "matrix-selections",
+ inputId: "matrix-selections-input",
+ headline: "Rate each item",
+ description: "Select a value for each row",
+ rows: defaultRows,
+ columns: defaultColumns,
+ value: {
+ "row-1": "col-2",
+ "row-2": "col-3",
+ },
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ elementId: "matrix-error",
+ inputId: "matrix-error-input",
+ headline: "Rate each item",
+ description: "Please select a value for each row",
+ rows: defaultRows,
+ columns: defaultColumns,
+ errorMessage: "Please complete all rows",
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ elementId: "matrix-disabled",
+ inputId: "matrix-disabled-input",
+ headline: "This element is disabled",
+ description: "You cannot change the selection",
+ rows: defaultRows,
+ columns: defaultColumns,
+ value: {
+ "row-1": "col-2",
+ "row-2": "col-3",
+ },
+ disabled: true,
+ },
+};
+
+export const RatingScale: Story = {
+ args: {
+ elementId: "matrix-rating-scale",
+ inputId: "matrix-rating-scale-input",
+ headline: "Rate your experience",
+ description: "How would you rate each aspect?",
+ rows: [
+ { id: "quality", label: "Quality" },
+ { id: "service", label: "Service" },
+ { id: "value", label: "Value for Money" },
+ { id: "support", label: "Customer Support" },
+ ],
+ columns: [
+ { id: "poor", label: "Poor" },
+ { id: "fair", label: "Fair" },
+ { id: "good", label: "Good" },
+ { id: "very-good", label: "Very Good" },
+ { id: "excellent", label: "Excellent" },
+ ],
+ },
+};
+
+export const NumericScale: Story = {
+ args: {
+ elementId: "matrix-numeric-scale",
+ inputId: "matrix-numeric-scale-input",
+ headline: "Rate from 0 to 10",
+ description: "Select a number for each item",
+ rows: [
+ { id: "item-1", label: "Item 1" },
+ { id: "item-2", label: "Item 2" },
+ { id: "item-3", label: "Item 3" },
+ ],
+ columns: [
+ { id: "0", label: "0" },
+ { id: "1", label: "1" },
+ { id: "2", label: "2" },
+ { id: "3", label: "3" },
+ { id: "4", label: "4" },
+ { id: "5", label: "5" },
+ { id: "6", label: "6" },
+ { id: "7", label: "7" },
+ { id: "8", label: "8" },
+ { id: "9", label: "9" },
+ { id: "10", label: "10" },
+ ],
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ elementId: "matrix-rtl",
+ inputId: "matrix-rtl-input",
+ headline: "قيم كل عنصر",
+ description: "اختر قيمة لكل صف",
+ dir: "rtl",
+ rows: [
+ { id: "row-1", label: "الصف الأول" },
+ { id: "row-2", label: "الصف الثاني" },
+ { id: "row-3", label: "الصف الثالث" },
+ ],
+ columns: [
+ { id: "col-1", label: "عمود 1" },
+ { id: "col-2", label: "عمود 2" },
+ { id: "col-3", label: "عمود 3" },
+ { id: "col-4", label: "عمود 4" },
+ ],
+ },
+};
+
+export const RTLWithSelections: Story = {
+ args: {
+ elementId: "matrix-rtl-selections",
+ inputId: "matrix-rtl-selections-input",
+ dir: "rtl",
+ headline: "قيم كل عنصر",
+ description: "يرجى اختيار قيمة لكل صف",
+ rows: [
+ { id: "quality", label: "الجودة" },
+ { id: "service", label: "الخدمة" },
+ { id: "value", label: "القيمة" },
+ ],
+ columns: [
+ { id: "poor", label: "ضعيف" },
+ { id: "fair", label: "مقبول" },
+ { id: "good", label: "جيد" },
+ { id: "very-good", label: "جيد جداً" },
+ { id: "excellent", label: "ممتاز" },
+ ],
+ value: {
+ quality: "good",
+ service: "very-good",
+ },
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/matrix.tsx b/packages/survey-ui/src/components/elements/matrix.tsx
new file mode 100644
index 0000000000..2eb050045d
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/matrix.tsx
@@ -0,0 +1,166 @@
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import * as React from "react";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { Label } from "@/components/general/label";
+import { RadioGroupItem } from "@/components/general/radio-group";
+import { cn } from "@/lib/utils";
+
+/**
+ * Option for matrix element rows and columns
+ */
+export interface MatrixOption {
+ /** Unique identifier for the option */
+ id: string;
+ /** Display label for the option */
+ label: string;
+}
+
+interface MatrixProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the matrix group */
+ inputId: string;
+ /** Array of row options (left side) */
+ rows: MatrixOption[];
+ /** Array of column options (top header) */
+ columns: MatrixOption[];
+ /** Currently selected values: Record mapping row ID to column ID */
+ value?: Record;
+ /** Callback function called when selection changes */
+ onChange: (value: Record) => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the options are disabled */
+ disabled?: boolean;
+}
+
+function Matrix({
+ elementId,
+ headline,
+ description,
+ inputId,
+ rows,
+ columns,
+ value = {},
+ onChange,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+}: Readonly): React.JSX.Element {
+ // Ensure value is always an object (value already has default of {})
+ const selectedValues = value;
+
+ // Check which rows have errors (no selection when required)
+ const hasError = Boolean(errorMessage);
+ const rowsWithErrors = hasError && required ? rows.filter((row) => !selectedValues[row.id]) : [];
+
+ const handleRowChange = (rowId: string, columnId: string): void => {
+ // Toggle: if same column is selected, deselect it
+ if (selectedValues[rowId] === columnId) {
+ // Create new object without the rowId property
+ const { [rowId]: _, ...rest } = selectedValues;
+ onChange(rest);
+ } else {
+ onChange({ ...selectedValues, [rowId]: columnId });
+ }
+ };
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Matrix Table */}
+
+
+
+ {/* Table container with overflow for mobile */}
+
+
+ {/* Column headers */}
+
+
+
+ {columns.map((column) => (
+
+ {column.label}
+
+ ))}
+
+
+ {/* Rows */}
+
+ {rows.map((row, index) => {
+ const rowGroupId = `${inputId}-row-${row.id}`;
+ const selectedColumnId = selectedValues[row.id];
+ const rowHasError = rowsWithErrors.includes(row);
+ const baseBgColor = index % 2 === 0 ? "bg-input-bg" : "bg-transparent";
+
+ return (
+ {
+ handleRowChange(row.id, newColumnId);
+ }}
+ disabled={disabled}
+ required={required}
+ aria-invalid={Boolean(errorMessage)}>
+
+ {/* Row label */}
+
+
+ {row.label}
+ {rowHasError ? (
+ Select one option
+ ) : null}
+
+
+ {/* Column options for this row */}
+ {columns.map((column, colIndex) => {
+ const cellId = `${rowGroupId}-${column.id}`;
+ const isLastColumn = colIndex === columns.length - 1;
+
+ return (
+
+
+
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+export { Matrix };
+export type { MatrixProps };
diff --git a/packages/survey-ui/src/components/elements/multi-select.stories.tsx b/packages/survey-ui/src/components/elements/multi-select.stories.tsx
new file mode 100644
index 0000000000..c3c05bc8ab
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/multi-select.stories.tsx
@@ -0,0 +1,353 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import React, { useEffect, useState } from "react";
+import {
+ type BaseStylingOptions,
+ type CheckboxInputStylingOptions,
+ type LabelStylingOptions,
+ type OptionStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ elementStylingArgTypes,
+ labelStylingArgTypes,
+ optionStylingArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { MultiSelect, type MultiSelectOption, type MultiSelectProps } from "./multi-select";
+
+type StoryProps = MultiSelectProps &
+ Partial &
+ Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/MultiSelect",
+ component: MultiSelect,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A complete multi-select element that combines headline, description, and checkbox options. Supports multiple selections, validation, and RTL text direction.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ options: {
+ control: "object",
+ description: "Array of options to choose from",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "object",
+ description: "Array of selected option IDs",
+ table: { category: "State" },
+ },
+ variant: {
+ control: { type: "select" },
+ options: ["list", "dropdown"],
+ description: "Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu",
+ table: { category: "Layout" },
+ },
+ placeholder: {
+ control: "text",
+ description: "Placeholder text for dropdown button when no options are selected",
+ table: { category: "Content" },
+ },
+ },
+ render: function Render(args: StoryProps) {
+ const [value, setValue] = useState(args.value);
+ const [otherValue, setOtherValue] = useState(args.otherValue);
+ const handleOtherValueChange = (v: string) => {
+ setOtherValue(v);
+ args.onOtherValueChange?.(v);
+ };
+
+ useEffect(() => {
+ setValue(args.value);
+ }, [args.value]);
+
+ return (
+ {
+ setValue(v);
+ args.onChange?.(v);
+ }}
+ otherValue={otherValue}
+ onOtherValueChange={handleOtherValueChange}
+ />
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const defaultOptions: MultiSelectOption[] = [
+ { id: "option-1", label: "Option 1" },
+ { id: "option-2", label: "Option 2" },
+ { id: "option-3", label: "Option 3" },
+ { id: "option-4", label: "Option 4" },
+];
+
+export const StylingPlayground: Story = {
+ args: {
+ headline: "Which features do you use?",
+ description: "Select all that apply",
+ options: defaultOptions,
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...labelStylingArgTypes,
+ ...optionStylingArgTypes,
+ ...surveyStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ headline: "Which features do you use?",
+ options: defaultOptions,
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "What programming languages do you know?",
+ description: "Select all programming languages you're familiar with",
+ options: [
+ { id: "js", label: "JavaScript" },
+ { id: "ts", label: "TypeScript" },
+ { id: "python", label: "Python" },
+ { id: "java", label: "Java" },
+ { id: "go", label: "Go" },
+ { id: "rust", label: "Rust" },
+ ],
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "Select your interests",
+ description: "Please select at least one option",
+ options: [
+ { id: "tech", label: "Technology" },
+ { id: "design", label: "Design" },
+ { id: "marketing", label: "Marketing" },
+ { id: "sales", label: "Sales" },
+ ],
+ required: true,
+ },
+};
+
+export const WithSelections: Story = {
+ args: {
+ headline: "Which features do you use?",
+ description: "Select all that apply",
+ options: defaultOptions,
+ value: ["option-1", "option-3"],
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ headline: "Select your preferences",
+ description: "Please select at least one option",
+ options: [
+ { id: "email", label: "Email notifications" },
+ { id: "sms", label: "SMS notifications" },
+ { id: "push", label: "Push notifications" },
+ ],
+ errorMessage: "Please select at least one option",
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ headline: "This element is disabled",
+ description: "You cannot change the selection",
+ options: defaultOptions,
+ value: ["option-2"],
+ disabled: true,
+ },
+};
+
+export const ManyOptions: Story = {
+ args: {
+ headline: "Select all that apply",
+ description: "Choose as many as you like",
+ options: [
+ { id: "1", label: "Option 1" },
+ { id: "2", label: "Option 2" },
+ { id: "3", label: "Option 3" },
+ { id: "4", label: "Option 4" },
+ { id: "5", label: "Option 5" },
+ { id: "6", label: "Option 6" },
+ { id: "7", label: "Option 7" },
+ { id: "8", label: "Option 8" },
+ { id: "9", label: "Option 9" },
+ { id: "10", label: "Option 10" },
+ ],
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ headline: "ما هي الميزات التي تستخدمها؟",
+ dir: "rtl",
+ description: "اختر كل ما ينطبق",
+ options: [
+ { id: "opt-1", label: "الخيار الأول" },
+ { id: "opt-2", label: "الخيار الثاني" },
+ { id: "opt-3", label: "الخيار الثالث" },
+ { id: "opt-4", label: "الخيار الرابع" },
+ ],
+ },
+};
+
+export const RTLWithSelections: Story = {
+ args: {
+ headline: "ما هي اهتماماتك؟",
+ dir: "rtl",
+ description: "يرجى اختيار جميع الخيارات المناسبة",
+ options: [
+ { id: "tech", label: "التكنولوجيا" },
+ { id: "design", label: "التصميم" },
+ { id: "marketing", label: "التسويق" },
+ { id: "sales", label: "المبيعات" },
+ ],
+ value: ["tech", "design"],
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
+
+export const Dropdown: Story = {
+ args: {
+ headline: "Which features do you use?",
+ description: "Select all that apply",
+ options: defaultOptions,
+ variant: "dropdown",
+ placeholder: "Select options...",
+ },
+};
+
+export const DropdownWithSelections: Story = {
+ args: {
+ headline: "Which features do you use?",
+ description: "Select all that apply",
+ options: defaultOptions,
+ value: ["option-1", "option-3"],
+ variant: "dropdown",
+ placeholder: "Select options...",
+ },
+};
+
+export const WithOtherOption: Story = {
+ render: () => {
+ const [value, setValue] = React.useState([]);
+ const [otherValue, setOtherValue] = React.useState("");
+
+ return (
+
+
+
+ );
+ },
+};
+
+export const WithOtherOptionSelected: Story = {
+ render: () => {
+ const [value, setValue] = React.useState(["option-1", "other"]);
+ const [otherValue, setOtherValue] = React.useState("Custom feature");
+
+ return (
+
+
+
+ );
+ },
+};
+
+export const DropdownWithOtherOption: Story = {
+ render: () => {
+ const [value, setValue] = React.useState([]);
+ const [otherValue, setOtherValue] = React.useState("");
+
+ return (
+
+
+
+ );
+ },
+};
diff --git a/packages/survey-ui/src/components/elements/multi-select.tsx b/packages/survey-ui/src/components/elements/multi-select.tsx
new file mode 100644
index 0000000000..129860fd32
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/multi-select.tsx
@@ -0,0 +1,549 @@
+import { ChevronDown } from "lucide-react";
+import * as React from "react";
+import { Button } from "@/components/general/button";
+import { Checkbox } from "@/components/general/checkbox";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/general/dropdown-menu";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { Input } from "@/components/general/input";
+import { cn } from "@/lib/utils";
+
+/**
+ * Option for multi-select element
+ */
+export interface MultiSelectOption {
+ /** Unique identifier for the option */
+ id: string;
+ /** Display label for the option */
+ label: string;
+}
+
+/**
+ * Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content)
+ */
+type TextDirection = "ltr" | "rtl" | "auto";
+
+interface MultiSelectProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the multi-select group */
+ inputId: string;
+ /** Array of options to choose from */
+ options: MultiSelectOption[];
+ /** Currently selected option IDs */
+ value?: string[];
+ /** Callback function called when selection changes */
+ onChange: (value: string[]) => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display below the options */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: TextDirection;
+ /** Whether the options are disabled */
+ disabled?: boolean;
+ /** Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu */
+ variant?: "list" | "dropdown";
+ /** Placeholder text for dropdown button when no options are selected */
+ placeholder?: string;
+ /** ID for the 'other' option that allows custom input */
+ otherOptionId?: string;
+ /** Label for the 'other' option */
+ otherOptionLabel?: string;
+ /** Placeholder text for the 'other' input field */
+ otherOptionPlaceholder?: string;
+ /** Custom value entered in the 'other' input field */
+ otherValue?: string;
+ /** Callback when the 'other' input value changes */
+ onOtherValueChange?: (value: string) => void;
+ /** IDs of options that should be exclusive (selecting them deselects all others) */
+ exclusiveOptionIds?: string[];
+}
+
+// Shared className for option labels
+const optionLabelClassName = "font-option text-option font-option-weight text-option-label";
+
+// Shared className for option containers
+const getOptionContainerClassName = (isSelected: boolean, isDisabled: boolean): string =>
+ cn(
+ "relative flex flex-col border transition-colors outline-none",
+ "rounded-option px-option-x py-option-y",
+ isSelected ? "bg-option-selected-bg border-brand" : "bg-option-bg border-option-border",
+ "focus-within:border-brand focus-within:bg-option-selected-bg",
+ "hover:bg-option-hover-bg",
+ isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
+ );
+
+interface DropdownVariantProps {
+ inputId: string;
+ options: MultiSelectOption[];
+ selectedValues: string[];
+ handleOptionAdd: (optionId: string) => void;
+ handleOptionRemove: (optionId: string) => void;
+ disabled: boolean;
+ headline: string;
+ errorMessage?: string;
+ displayText: string;
+ hasOtherOption: boolean;
+ otherOptionId?: string;
+ isOtherSelected: boolean;
+ otherOptionLabel: string;
+ otherValue: string;
+ handleOtherInputChange: (e: React.ChangeEvent) => void;
+ otherOptionPlaceholder: string;
+ dir: TextDirection;
+ otherInputRef: React.RefObject;
+ required: boolean;
+}
+
+function DropdownVariant({
+ inputId,
+ options,
+ selectedValues,
+ handleOptionAdd,
+ handleOptionRemove,
+ disabled,
+ headline,
+ errorMessage,
+ displayText,
+ hasOtherOption,
+ otherOptionId,
+ isOtherSelected,
+ otherOptionLabel,
+ otherValue,
+ handleOtherInputChange,
+ otherOptionPlaceholder,
+ dir,
+ otherInputRef,
+ required,
+}: Readonly): React.JSX.Element {
+ const getIsRequired = (): boolean => {
+ const responseValues = [...selectedValues];
+ if (isOtherSelected && otherValue) {
+ responseValues.push(otherValue);
+ }
+ const hasResponse = Array.isArray(responseValues) && responseValues.length > 0;
+ return required && hasResponse ? false : required;
+ };
+
+ const isRequired = getIsRequired();
+
+ const handleOptionToggle = (optionId: string) => {
+ if (selectedValues.includes(optionId)) {
+ handleOptionRemove(optionId);
+ } else {
+ handleOptionAdd(optionId);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ {displayText}
+
+
+
+
+ {options
+ .filter((option) => option.id !== "none")
+ .map((option) => {
+ const isChecked = selectedValues.includes(option.id);
+ const optionId = `${inputId}-${option.id}`;
+
+ return (
+ {
+ handleOptionToggle(option.id);
+ }}
+ disabled={disabled}>
+ {option.label}
+
+ );
+ })}
+ {hasOtherOption && otherOptionId ? (
+ {
+ if (isOtherSelected) {
+ handleOptionRemove(otherOptionId);
+ } else {
+ handleOptionAdd(otherOptionId);
+ }
+ }}
+ disabled={disabled}>
+ {otherOptionLabel}
+
+ ) : null}
+ {options
+ .filter((option) => option.id === "none")
+ .map((option) => {
+ const isChecked = selectedValues.includes(option.id);
+ const optionId = `${inputId}-${option.id}`;
+
+ return (
+ {
+ handleOptionToggle(option.id);
+ }}
+ disabled={disabled}>
+ {option.label}
+
+ );
+ })}
+
+
+ {isOtherSelected ? (
+
+ ) : null}
+ >
+ );
+}
+
+interface ListVariantProps {
+ inputId: string;
+ options: MultiSelectOption[];
+ selectedValues: string[];
+ value: string[];
+ handleOptionAdd: (optionId: string) => void;
+ handleOptionRemove: (optionId: string) => void;
+ disabled: boolean;
+ headline: string;
+ errorMessage?: string;
+ hasOtherOption: boolean;
+ otherOptionId?: string;
+ isOtherSelected: boolean;
+ otherOptionLabel: string;
+ otherValue: string;
+ handleOtherInputChange: (e: React.ChangeEvent) => void;
+ otherOptionPlaceholder: string;
+ dir: TextDirection;
+ otherInputRef: React.RefObject;
+ required: boolean;
+}
+
+function ListVariant({
+ inputId,
+ options,
+ selectedValues,
+ value,
+ handleOptionAdd,
+ handleOptionRemove,
+ disabled,
+ headline,
+ errorMessage,
+ hasOtherOption,
+ otherOptionId,
+ isOtherSelected,
+ otherOptionLabel,
+ otherValue,
+ handleOtherInputChange,
+ otherOptionPlaceholder,
+ dir,
+ otherInputRef,
+ required,
+}: Readonly): React.JSX.Element {
+ const isNoneSelected = value.includes("none");
+
+ const getIsRequired = (): boolean => {
+ const responseValues = [...value];
+ if (isOtherSelected && otherValue) {
+ responseValues.push(otherValue);
+ }
+ const hasResponse = Array.isArray(responseValues) && responseValues.length > 0;
+ return required && hasResponse ? false : required;
+ };
+
+ const isRequired = getIsRequired();
+
+ return (
+ <>
+
+
+ {options
+ .filter((option) => option.id !== "none")
+ .map((option, index) => {
+ const isChecked = selectedValues.includes(option.id);
+ const optionId = `${inputId}-${option.id}`;
+ const isDisabled = disabled || (isNoneSelected && option.id !== "none");
+ // Only mark the first checkbox as required for HTML5 validation
+ // This ensures at least one selection is required, not all
+ const isFirstOption = index === 0;
+ return (
+
+
+ {
+ if (checked === true) {
+ handleOptionAdd(option.id);
+ } else {
+ handleOptionRemove(option.id);
+ }
+ }}
+ disabled={isDisabled}
+ required={isRequired ? isFirstOption : false}
+ aria-invalid={Boolean(errorMessage)}
+ />
+
+ {option.label}
+
+
+
+ );
+ })}
+ {hasOtherOption && otherOptionId ? (
+
+
+
+ {
+ if (checked === true) {
+ handleOptionAdd(otherOptionId);
+ } else {
+ handleOptionRemove(otherOptionId);
+ }
+ }}
+ disabled={disabled || isNoneSelected}
+ aria-invalid={Boolean(errorMessage)}
+ />
+
+ {otherOptionLabel}
+
+
+ {isOtherSelected ? (
+
+ ) : null}
+
+
+ ) : null}
+ {options
+ .filter((option) => option.id === "none")
+ .map((option) => {
+ const isChecked = selectedValues.includes(option.id);
+ const optionId = `${inputId}-${option.id}`;
+ const isDisabled = disabled || (isNoneSelected && option.id !== "none");
+ return (
+
+
+ {
+ if (checked === true) {
+ handleOptionAdd(option.id);
+ } else {
+ handleOptionRemove(option.id);
+ }
+ }}
+ disabled={isDisabled}
+ required={false}
+ aria-invalid={Boolean(errorMessage)}
+ />
+
+ {option.label}
+
+
+
+ );
+ })}
+
+ >
+ );
+}
+
+function MultiSelect({
+ elementId,
+ headline,
+ description,
+ inputId,
+ options,
+ value = [],
+ onChange,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+ variant = "list",
+ placeholder = "Select options...",
+ otherOptionId,
+ otherOptionLabel = "Other",
+ otherOptionPlaceholder = "Please specify",
+ otherValue = "",
+ onOtherValueChange,
+ exclusiveOptionIds = [],
+}: Readonly): React.JSX.Element {
+ // Ensure value is always an array
+ const selectedValues = Array.isArray(value) ? value : [];
+ const hasOtherOption = Boolean(otherOptionId);
+ const isOtherSelected = Boolean(hasOtherOption && otherOptionId && selectedValues.includes(otherOptionId));
+ const otherInputRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!isOtherSelected || disabled) return;
+
+ // Delay focus to win against Radix focus restoration when dropdown closes / checkbox receives focus.
+ const timeoutId = globalThis.setTimeout(() => {
+ globalThis.requestAnimationFrame(() => {
+ otherInputRef.current?.focus();
+ });
+ }, 0);
+
+ return () => {
+ globalThis.clearTimeout(timeoutId);
+ };
+ }, [isOtherSelected, disabled, variant]);
+
+ const handleOptionAdd = (optionId: string): void => {
+ if (exclusiveOptionIds.includes(optionId)) {
+ onChange([optionId]);
+ } else {
+ const newValues = selectedValues.filter((id) => !exclusiveOptionIds.includes(id));
+ onChange([...newValues, optionId]);
+ }
+ };
+
+ const handleOptionRemove = (optionId: string): void => {
+ onChange(selectedValues.filter((id) => id !== optionId));
+ };
+
+ const handleOtherInputChange = (e: React.ChangeEvent): void => {
+ onOtherValueChange?.(e.target.value);
+ };
+
+ // Get selected option labels for dropdown display
+ const selectedLabels = options.filter((opt) => selectedValues.includes(opt.id)).map((opt) => opt.label);
+
+ let displayText = placeholder;
+ if (selectedLabels.length > 0) {
+ displayText =
+ selectedLabels.length === 1 ? selectedLabels[0] : `${String(selectedLabels.length)} selected`;
+ }
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Options */}
+
+ {variant === "dropdown" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export { MultiSelect };
+export type { MultiSelectProps };
diff --git a/packages/survey-ui/src/components/elements/nps.stories.tsx b/packages/survey-ui/src/components/elements/nps.stories.tsx
new file mode 100644
index 0000000000..98fc5eb014
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/nps.stories.tsx
@@ -0,0 +1,244 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ type BaseStylingOptions,
+ type LabelStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+ elementStylingArgTypes,
+ inputStylingArgTypes,
+ labelStylingArgTypes,
+ pickArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { NPS, type NPSProps } from "./nps";
+
+type StoryProps = NPSProps & Partial & Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/NPS",
+ component: NPS,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A Net Promoter Score (NPS) element. Users can select a rating from 0 to 10 to indicate how likely they are to recommend something.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ value: {
+ control: { type: "number", min: 0, max: 10 },
+ description: "Currently selected NPS value (0-10)",
+ table: { category: "State" },
+ },
+ lowerLabel: {
+ control: "text",
+ description: "Label for the lower end of the scale",
+ table: { category: "Content" },
+ },
+ upperLabel: {
+ control: "text",
+ description: "Label for the upper end of the scale",
+ table: { category: "Content" },
+ },
+ colorCoding: {
+ control: "boolean",
+ description: "Whether color coding is enabled",
+ table: { category: "Content" },
+ },
+ },
+ render: createStatefulRender(NPS),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const StylingPlayground: Story = {
+ args: {
+ elementId: "nps-1",
+ inputId: "nps-input-1",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ description: "Please rate from 0 to 10",
+ lowerLabel: "Not at all likely",
+ upperLabel: "Extremely likely",
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...labelStylingArgTypes,
+ ...pickArgTypes(inputStylingArgTypes, [
+ "inputBgColor",
+ "inputBorderColor",
+ "inputColor",
+ "inputFontWeight",
+ "inputBorderRadius",
+ ]),
+ ...surveyStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ elementId: "nps-1",
+ inputId: "nps-input-1",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ elementId: "nps-2",
+ inputId: "nps-input-2",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ description: "Please rate from 0 to 10, where 0 is not at all likely and 10 is extremely likely",
+ },
+};
+
+export const WithLabels: Story = {
+ args: {
+ elementId: "nps-labels",
+ inputId: "nps-input-labels",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ lowerLabel: "Not at all likely",
+ upperLabel: "Extremely likely",
+ },
+};
+
+export const WithSelection: Story = {
+ args: {
+ elementId: "nps-selection",
+ inputId: "nps-input-selection",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ value: 9,
+ },
+};
+
+export const Required: Story = {
+ args: {
+ elementId: "nps-required",
+ inputId: "nps-input-required",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ required: true,
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ elementId: "nps-error",
+ inputId: "nps-input-error",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ required: true,
+ errorMessage: "Please select a rating",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ elementId: "nps-disabled",
+ inputId: "nps-input-disabled",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ value: 8,
+ disabled: true,
+ },
+};
+
+export const ColorCoding: Story = {
+ args: {
+ elementId: "nps-color",
+ inputId: "nps-input-color",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ colorCoding: true,
+ lowerLabel: "Not at all likely",
+ upperLabel: "Extremely likely",
+ },
+};
+
+export const Promoter: Story = {
+ args: {
+ elementId: "nps-promoter",
+ inputId: "nps-input-promoter",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ value: 9,
+ colorCoding: true,
+ lowerLabel: "Not at all likely",
+ upperLabel: "Extremely likely",
+ },
+};
+
+export const Passive: Story = {
+ args: {
+ elementId: "nps-passive",
+ inputId: "nps-input-passive",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ value: 7,
+ colorCoding: true,
+ lowerLabel: "Not at all likely",
+ upperLabel: "Extremely likely",
+ },
+};
+
+export const Detractor: Story = {
+ args: {
+ elementId: "nps-detractor",
+ inputId: "nps-input-detractor",
+ headline: "How likely are you to recommend us to a friend or colleague?",
+ value: 5,
+ colorCoding: true,
+ lowerLabel: "Not at all likely",
+ upperLabel: "Extremely likely",
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ elementId: "nps-rtl",
+ dir: "rtl",
+ inputId: "nps-input-rtl",
+ headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟",
+ description: "يرجى التقييم من 0 إلى 10",
+ lowerLabel: "غير محتمل على الإطلاق",
+ upperLabel: "محتمل للغاية",
+ },
+};
+
+export const RTLWithSelection: Story = {
+ args: {
+ elementId: "nps-rtl-selection",
+ dir: "rtl",
+ inputId: "nps-input-rtl-selection",
+ headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟",
+ value: 8,
+ lowerLabel: "غير محتمل على الإطلاق",
+ upperLabel: "محتمل للغاية",
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/nps.tsx b/packages/survey-ui/src/components/elements/nps.tsx
new file mode 100644
index 0000000000..fe3a7cfb9c
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/nps.tsx
@@ -0,0 +1,196 @@
+import * as React from "react";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { Label } from "@/components/general/label";
+import { cn } from "@/lib/utils";
+
+interface NPSProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the NPS group */
+ inputId: string;
+ /** Currently selected NPS value (0 to 10) */
+ value?: number;
+ /** Callback function called when NPS value changes */
+ onChange: (value: number) => void;
+ /** Optional label for the lower end of the scale */
+ lowerLabel?: string;
+ /** Optional label for the upper end of the scale */
+ upperLabel?: string;
+ /** Whether color coding is enabled */
+ colorCoding?: boolean;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the controls are disabled */
+ disabled?: boolean;
+}
+
+function NPS({
+ elementId,
+ headline,
+ description,
+ inputId,
+ value,
+ onChange,
+ lowerLabel,
+ upperLabel,
+ colorCoding = false,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+}: Readonly): React.JSX.Element {
+ const [hoveredValue, setHoveredValue] = React.useState(null);
+
+ // Ensure value is within valid range (0-10)
+ const currentValue = value !== undefined && value >= 0 && value <= 10 ? value : undefined;
+
+ // Handle NPS selection
+ const handleSelect = (npsValue: number): void => {
+ if (!disabled) {
+ onChange(npsValue);
+ }
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (npsValue: number) => (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleSelect(npsValue);
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
+ e.preventDefault();
+ const direction = e.key === "ArrowLeft" ? -1 : 1;
+ const newValue = Math.max(0, Math.min(10, (currentValue ?? 0) + direction));
+ handleSelect(newValue);
+ }
+ };
+
+ // Get NPS option color for color coding
+ const getNPSOptionColor = (idx: number): string => {
+ if (idx > 8) return "bg-emerald-100"; // 9-10: Promoters (green)
+ if (idx > 6) return "bg-orange-100"; // 7-8: Passives (orange)
+ return "bg-rose-100"; // 0-6: Detractors (red)
+ };
+
+ // Render NPS option (0-10)
+ const renderNPSOption = (number: number): React.JSX.Element => {
+ const isSelected = currentValue === number;
+ const isHovered = hoveredValue === number;
+ const isLast = number === 10; // Last option is 10
+ const isFirst = number === 0; // First option is 0
+
+ // Determine border radius and border classes
+ // Use right border for all items to create separators, left border only on first item
+ let borderRadiusClasses = "";
+ let borderClasses = "border-t border-b border-r";
+
+ if (isFirst) {
+ borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input";
+ borderClasses = "border-t border-b border-l border-r";
+ } else if (isLast) {
+ borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input";
+ // Last item keeps right border for rounded corner
+ }
+
+ return (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
+ {
+ if (!disabled) {
+ setHoveredValue(number);
+ }
+ }}
+ onMouseLeave={() => {
+ setHoveredValue(null);
+ }}
+ onFocus={() => {
+ if (!disabled) {
+ setHoveredValue(number);
+ }
+ }}
+ onBlur={() => {
+ setHoveredValue(null);
+ }}>
+ {colorCoding ? (
+
+ ) : null}
+ {
+ handleSelect(number);
+ }}
+ disabled={disabled}
+ required={required}
+ className="sr-only"
+ aria-label={`Rate ${String(number)} out of 10`}
+ />
+ {number}
+
+ );
+ };
+
+ // Generate NPS options (0-10)
+ const npsOptions = Array.from({ length: 11 }, (_, i) => i);
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* NPS Options */}
+
+
+
+ NPS rating options
+ {npsOptions.map((number) => renderNPSOption(number))}
+
+ {/* Labels */}
+ {(lowerLabel ?? upperLabel) ? (
+
+ {lowerLabel ? (
+
+ {lowerLabel}
+
+ ) : null}
+ {upperLabel ? (
+
+ {upperLabel}
+
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+}
+
+export { NPS };
+export type { NPSProps };
diff --git a/packages/survey-ui/src/components/elements/open-text.stories.tsx b/packages/survey-ui/src/components/elements/open-text.stories.tsx
new file mode 100644
index 0000000000..dcb8dbe4a2
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/open-text.stories.tsx
@@ -0,0 +1,279 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ type BaseStylingOptions,
+ type InputLayoutStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+ elementStylingArgTypes,
+ inputStylingArgTypes,
+ pickArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { OpenText, type OpenTextProps } from "./open-text";
+
+type StoryProps = OpenTextProps &
+ Partial &
+ Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/OpenText",
+ component: OpenText,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A complete open text element that combines headline, description, and input/textarea components. Supports short and long answers, validation, character limits, and RTL text direction.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ placeholder: {
+ control: "text",
+ description: "Placeholder text for the input field",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "text",
+ description: "Current input value",
+ table: { category: "State" },
+ },
+ longAnswer: {
+ control: "boolean",
+ description: "Use textarea for long-form answers instead of input",
+ table: { category: "Layout" },
+ },
+ inputType: {
+ control: { type: "select" },
+ options: ["text", "email", "url", "phone", "number"],
+ description: "Type of input field (only used when longAnswer is false)",
+ table: { category: "Validation" },
+ },
+ charLimit: {
+ control: "object",
+ description: "Character limit configuration {min?, max?}",
+ table: { category: "Validation" },
+ },
+ rows: {
+ control: { type: "number", min: 1, max: 20 },
+ description: "Number of rows for textarea (only when longAnswer is true)",
+ table: { category: "Layout" },
+ },
+ },
+ render: createStatefulRender(OpenText),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const StylingPlayground: Story = {
+ args: {
+ headline: "What's your feedback?",
+ description: "Please share your thoughts with us",
+ placeholder: "Type your answer here...",
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...pickArgTypes(inputStylingArgTypes, [
+ "inputBgColor",
+ "inputBorderColor",
+ "inputColor",
+ "inputFontSize",
+ "inputFontWeight",
+ "inputWidth",
+ "inputHeight",
+ "inputBorderRadius",
+ "inputPlaceholderColor",
+ "inputPaddingX",
+ "inputPaddingY",
+ ]),
+ ...surveyStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ headline: "What's your feedback?",
+ placeholder: "Type your answer here...",
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "What did you think of our service?",
+ description: "We'd love to hear your honest feedback to help us improve",
+ placeholder: "Share your thoughts...",
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "What's your email address?",
+ description: "We'll use this to contact you",
+ placeholder: "email@example.com",
+ required: true,
+ inputType: "email",
+ },
+};
+
+export const LongAnswer: Story = {
+ args: {
+ headline: "Tell us about your experience",
+ description: "Please provide as much detail as possible",
+ placeholder: "Write your detailed response here...",
+ longAnswer: true,
+ rows: 5,
+ },
+};
+
+export const LongAnswerWithCharLimit: Story = {
+ args: {
+ headline: "Share your story",
+ description: "Maximum 500 characters",
+ placeholder: "Tell us your story...",
+ longAnswer: true,
+ rows: 6,
+ charLimit: {
+ max: 500,
+ },
+ },
+};
+
+export const EmailInput: Story = {
+ args: {
+ headline: "What's your email?",
+ inputType: "email",
+ placeholder: "email@example.com",
+ required: true,
+ },
+};
+
+export const PhoneInput: Story = {
+ args: {
+ headline: "What's your phone number?",
+ description: "Include country code",
+ inputType: "phone",
+ placeholder: "+1 (555) 123-4567",
+ },
+};
+
+export const URLInput: Story = {
+ args: {
+ headline: "What's your website?",
+ inputType: "url",
+ placeholder: "https://example.com",
+ },
+};
+
+export const NumberInput: Story = {
+ args: {
+ headline: "How many employees does your company have?",
+ inputType: "number",
+ placeholder: "0",
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ headline: "What's your email address?",
+ inputType: "email",
+ placeholder: "email@example.com",
+ value: "invalid-email",
+ errorMessage: "Please enter a valid email address",
+ required: true,
+ },
+};
+
+export const WithValue: Story = {
+ args: {
+ headline: "What's your name?",
+ placeholder: "Enter your name",
+ value: "John Doe",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ headline: "This field is disabled",
+ description: "You cannot edit this field",
+ placeholder: "Disabled input",
+ disabled: true,
+ },
+};
+
+export const DisabledWithValue: Story = {
+ args: {
+ headline: "Submission ID",
+ value: "SUB-2024-001",
+ disabled: true,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ headline: "ما هو تقييمك؟",
+ description: "يرجى مشاركة أفكارك معنا",
+ placeholder: "اكتب إجابتك هنا...",
+ dir: "rtl",
+ },
+};
+
+export const RTLLongAnswer: Story = {
+ args: {
+ headline: "أخبرنا عن تجربتك",
+ description: "يرجى تقديم أكبر قدر ممكن من التفاصيل",
+ placeholder: "اكتب ردك التفصيلي هنا...",
+ longAnswer: true,
+ rows: 5,
+ dir: "rtl",
+ },
+};
+
+export const WithErrorAndRTL: Story = {
+ args: {
+ headline: "ما هو بريدك الإلكتروني؟",
+ inputType: "email",
+ placeholder: "email@example.com",
+ errorMessage: "يرجى إدخال عنوان بريد إلكتروني صالح",
+ required: true,
+ dir: "rtl",
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/open-text.tsx b/packages/survey-ui/src/components/elements/open-text.tsx
new file mode 100644
index 0000000000..6768739b4e
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/open-text.tsx
@@ -0,0 +1,110 @@
+import { useState } from "react";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { Input } from "@/components/general/input";
+import { Textarea } from "@/components/general/textarea";
+import { cn } from "@/lib/utils";
+
+interface OpenTextProps {
+ elementId: string;
+ headline: string;
+ description?: string;
+ placeholder?: string;
+ inputId: string;
+ value?: string;
+ onChange: (value: string) => void;
+ required?: boolean;
+ longAnswer?: boolean;
+ inputType?: "text" | "email" | "url" | "phone" | "number";
+ charLimit?: {
+ min?: number;
+ max?: number;
+ };
+ errorMessage?: string;
+ dir?: "ltr" | "rtl" | "auto";
+ rows?: number;
+ disabled?: boolean;
+}
+
+function OpenText({
+ elementId,
+ headline,
+ description,
+ placeholder,
+ value = "",
+ inputId,
+ onChange,
+ required = false,
+ longAnswer = false,
+ inputType = "text",
+ charLimit,
+ errorMessage,
+ dir = "auto",
+ rows = 3,
+ disabled = false,
+}: Readonly): React.JSX.Element {
+ const [currentLength, setCurrentLength] = useState(value.length);
+
+ const handleChange = (e: React.ChangeEvent): void => {
+ const newValue = e.target.value;
+ setCurrentLength(newValue.length);
+ onChange(newValue);
+ };
+
+ const renderCharLimit = (): React.JSX.Element | null => {
+ if (charLimit?.max === undefined) return null;
+ const isOverLimit = currentLength >= charLimit.max;
+ return (
+
+ {currentLength}/{charLimit.max}
+
+ );
+ };
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Input or Textarea */}
+
+
+
+ {longAnswer ? (
+
+ ) : (
+
+ )}
+ {renderCharLimit()}
+
+
+
+ );
+}
+
+export { OpenText };
+export type { OpenTextProps };
diff --git a/packages/survey-ui/src/components/elements/picture-select.stories.tsx b/packages/survey-ui/src/components/elements/picture-select.stories.tsx
new file mode 100644
index 0000000000..b12fa9e499
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/picture-select.stories.tsx
@@ -0,0 +1,282 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import * as React from "react";
+import {
+ type BaseStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+} from "../../lib/story-helpers";
+import { PictureSelect, type PictureSelectOption, type PictureSelectProps } from "./picture-select";
+
+type StoryProps = PictureSelectProps &
+ Partial &
+ Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/PictureSelect",
+ component: PictureSelect,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A complete picture selection element that combines headline, description, and a grid of selectable images. Supports both single and multi-select modes, validation, and RTL text direction.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ options: {
+ control: "object",
+ description: "Array of picture options to choose from",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "object",
+ description: "Selected option ID(s) - string for single select, string[] for multi select",
+ table: { category: "State" },
+ },
+ allowMulti: {
+ control: "boolean",
+ description: "Whether multiple selections are allowed",
+ table: { category: "Behavior" },
+ },
+ },
+ render: createStatefulRender(PictureSelect),
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+
+// Sample image URLs - using placeholder images
+const defaultOptions: PictureSelectOption[] = [
+ {
+ id: "option-1",
+ imageUrl: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=300&h=200&fit=crop",
+ alt: "Mountain landscape",
+ },
+ {
+ id: "option-2",
+ imageUrl: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=300&h=200&fit=crop",
+ alt: "Ocean view",
+ },
+ {
+ id: "option-3",
+ imageUrl: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=300&h=200&fit=crop",
+ alt: "Forest path",
+ },
+ {
+ id: "option-4",
+ imageUrl: "https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=300&h=200&fit=crop",
+ alt: "Desert scene",
+ },
+];
+
+export const StylingPlayground: Story = {
+ args: {
+ headline: "Which image do you prefer?",
+ description: "Select one or more images",
+ options: defaultOptions,
+ allowMulti: false,
+ },
+ argTypes: {
+ // Element styling
+ elementHeadlineFontFamily: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementHeadlineFontSize: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementHeadlineFontWeight: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementHeadlineColor: {
+ control: "color",
+ table: { category: "Element Styling" },
+ },
+ elementDescriptionFontFamily: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementDescriptionFontSize: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementDescriptionFontWeight: {
+ control: "text",
+ table: { category: "Element Styling" },
+ },
+ elementDescriptionColor: {
+ control: "color",
+ table: { category: "Element Styling" },
+ },
+ brandColor: {
+ control: "color",
+ table: { category: "Survey Styling" },
+ },
+ optionBorderRadius: {
+ control: "text",
+ description: "Border radius for picture options",
+ table: { category: "Option Styling" },
+ },
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ headline: "Which image do you prefer?",
+ options: defaultOptions,
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "Select your favorite travel destination",
+ description: "Choose the image that appeals to you most",
+ options: defaultOptions,
+ },
+};
+
+export const SingleSelect: Story = {
+ args: {
+ headline: "Which image do you prefer?",
+ description: "Select one image",
+ options: defaultOptions,
+ allowMulti: false,
+ },
+};
+
+export const MultiSelect: Story = {
+ args: {
+ headline: "Select all images you like",
+ description: "You can select multiple images",
+ options: defaultOptions,
+ allowMulti: true,
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "Which image do you prefer?",
+ description: "Please select an image",
+ options: defaultOptions,
+ required: true,
+ },
+};
+
+export const WithSelection: Story = {
+ args: {
+ headline: "Which image do you prefer?",
+ options: defaultOptions,
+ value: "option-2",
+ },
+};
+
+export const WithMultipleSelections: Story = {
+ args: {
+ headline: "Select all images you like",
+ description: "You can select multiple images",
+ options: defaultOptions,
+ allowMulti: true,
+ value: ["option-1", "option-3"],
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ headline: "Which image do you prefer?",
+ description: "Please select an image",
+ options: defaultOptions,
+ errorMessage: "Please select at least one image",
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ headline: "This element is disabled",
+ description: "You cannot change the selection",
+ options: defaultOptions,
+ value: "option-2",
+ disabled: true,
+ },
+};
+
+export const ManyOptions: Story = {
+ args: {
+ headline: "Select your favorite images",
+ description: "Choose from the images below",
+ options: [
+ ...defaultOptions,
+ {
+ id: "option-5",
+ imageUrl: "https://images.unsplash.com/photo-1519681393784-d120267933ba?w=300&h=200&fit=crop",
+ alt: "City skyline",
+ },
+ {
+ id: "option-6",
+ imageUrl: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=300&h=200&fit=crop",
+ alt: "Sunset",
+ },
+ ],
+ allowMulti: true,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ headline: "ما هي الصورة التي تفضلها؟",
+ dir: "rtl",
+ description: "اختر صورة واحدة",
+ options: defaultOptions.map((opt) => ({ ...opt, alt: "نص بديل" })),
+ },
+};
+
+export const RTLWithSelection: Story = {
+ args: {
+ headline: "اختر الصور التي تعجبك",
+ dir: "rtl",
+ description: "يمكنك اختيار عدة صور",
+ options: defaultOptions.map((opt) => ({ ...opt, alt: "نص بديل" })),
+ allowMulti: true,
+ value: ["option-1", "option-2"],
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => {
+ const [value1, setValue1] = React.useState(undefined);
+ const [value2, setValue2] = React.useState(["option-1", "option-3"]);
+
+ return (
+
+ );
+ },
+};
diff --git a/packages/survey-ui/src/components/elements/picture-select.tsx b/packages/survey-ui/src/components/elements/picture-select.tsx
new file mode 100644
index 0000000000..165ca696ef
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/picture-select.tsx
@@ -0,0 +1,198 @@
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import * as React from "react";
+import { Checkbox } from "@/components/general/checkbox";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { RadioGroupItem } from "@/components/general/radio-group";
+import { cn } from "@/lib/utils";
+
+/**
+ * Picture option for picture select element
+ */
+export interface PictureSelectOption {
+ /** Unique identifier for the option */
+ id: string;
+ /** URL of the image */
+ imageUrl: string;
+ /** Alt text for the image */
+ alt?: string;
+}
+
+interface PictureSelectProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the picture select group */
+ inputId: string;
+ /** Array of picture options to choose from */
+ options: PictureSelectOption[];
+ /** Currently selected option ID(s) - string for single select, string[] for multi select */
+ value?: string | string[];
+ /** Callback function called when selection changes */
+ onChange: (value: string | string[]) => void;
+ /** Whether multiple selections are allowed */
+ allowMulti?: boolean;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the options are disabled */
+ disabled?: boolean;
+}
+
+function PictureSelect({
+ elementId,
+ headline,
+ description,
+ inputId,
+ options,
+ value,
+ onChange,
+ allowMulti = false,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+}: Readonly): React.JSX.Element {
+ // Ensure value is always the correct type
+ let selectedValues: string[] | string | undefined;
+ if (allowMulti) {
+ selectedValues = Array.isArray(value) ? value : [];
+ } else {
+ selectedValues = typeof value === "string" ? value : undefined;
+ }
+
+ const handleMultiSelectChange = (optionId: string, checked: boolean): void => {
+ if (disabled) return;
+
+ const currentArray = Array.isArray(value) ? value : [];
+ if (checked) {
+ onChange([...currentArray, optionId]);
+ } else {
+ onChange(currentArray.filter((id) => id !== optionId));
+ }
+ };
+
+ const handleSingleSelectChange = (newValue: string): void => {
+ if (disabled) return;
+ onChange(newValue);
+ };
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Picture Grid - 2 columns */}
+
+
+ {allowMulti ? (
+
+ {options.map((option) => {
+ const isSelected = (selectedValues as string[]).includes(option.id);
+ const optionId = `${inputId}-${option.id}`;
+
+ return (
+
+ {/* Image container with border when selected */}
+
+
+
+ {/* Selection indicator - Checkbox for multi select */}
+ {
+ e.stopPropagation();
+ }}>
+ {
+ handleMultiSelectChange(option.id, checked === true);
+ }}
+ disabled={disabled}
+ className="h-4 w-4"
+ aria-label={option.alt ?? `Select ${option.id}`}
+ />
+
+
+ );
+ })}
+
+ ) : (
+
+ {options.map((option) => {
+ const optionId = `${inputId}-${option.id}`;
+ const isSelected = selectedValues === option.id;
+
+ return (
+
+ {/* Image container with border when selected */}
+
+
+
+ {/* Selection indicator - Radio button for single select */}
+ {
+ e.stopPropagation();
+ }}>
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+export { PictureSelect };
+export type { PictureSelectProps };
diff --git a/packages/survey-ui/src/components/elements/ranking.stories.tsx b/packages/survey-ui/src/components/elements/ranking.stories.tsx
new file mode 100644
index 0000000000..d348a491a4
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/ranking.stories.tsx
@@ -0,0 +1,222 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import {
+ type BaseStylingOptions,
+ type OptionStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+ elementStylingArgTypes,
+ optionStylingArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { Ranking, type RankingOption, type RankingProps } from "./ranking";
+
+type StoryProps = RankingProps & Partial & Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/Ranking",
+ component: Ranking,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A ranking element that allows users to order items by clicking them. Users can reorder ranked items using up/down buttons.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ options: {
+ control: "object",
+ description: "Array of options to rank",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "object",
+ description: "Currently ranked option IDs in order",
+ table: { category: "State" },
+ },
+ },
+ render: createStatefulRender(Ranking),
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+
+// Default options for stories
+const defaultOptions: RankingOption[] = [
+ { id: "1", label: "Option 1" },
+ { id: "2", label: "Option 2" },
+ { id: "3", label: "Option 3" },
+ { id: "4", label: "Option 4" },
+ { id: "5", label: "Option 5" },
+];
+
+export const StylingPlayground: Story = {
+ args: {
+ elementId: "ranking-1",
+ inputId: "ranking-input-1",
+ headline: "Rank these items in order of importance",
+ description: "Click items to add them to your ranking, then use arrows to reorder",
+ options: defaultOptions,
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...optionStylingArgTypes,
+ ...surveyStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ elementId: "ranking-1",
+ inputId: "ranking-input-1",
+ headline: "Rank these items in order of importance",
+ options: defaultOptions,
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ elementId: "ranking-2",
+ inputId: "ranking-input-2",
+ headline: "Rank these items in order of importance",
+ description: "Click items to add them to your ranking, then use the arrows to reorder them",
+ options: defaultOptions,
+ },
+};
+
+export const WithRanking: Story = {
+ args: {
+ elementId: "ranking-3",
+ inputId: "ranking-input-3",
+ headline: "Rank these items in order of importance",
+ options: defaultOptions,
+ value: ["3", "1", "5"],
+ },
+};
+
+export const FullyRanked: Story = {
+ args: {
+ elementId: "ranking-4",
+ inputId: "ranking-input-4",
+ headline: "Rank these items in order of importance",
+ options: defaultOptions,
+ value: ["5", "2", "1", "4", "3"],
+ },
+};
+
+export const Required: Story = {
+ args: {
+ elementId: "ranking-5",
+ inputId: "ranking-input-5",
+ headline: "Rank these items in order of importance",
+ options: defaultOptions,
+ required: true,
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ elementId: "ranking-6",
+ inputId: "ranking-input-6",
+ headline: "Rank these items in order of importance",
+ options: defaultOptions,
+ required: true,
+ errorMessage: "Please rank all items",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ elementId: "ranking-7",
+ inputId: "ranking-input-7",
+ headline: "Rank these items in order of importance",
+ options: defaultOptions,
+ value: ["2", "4"],
+ disabled: true,
+ },
+};
+
+export const ManyOptions: Story = {
+ args: {
+ elementId: "ranking-8",
+ inputId: "ranking-input-8",
+ headline: "Rank these features by priority",
+ description: "Click to add to ranking, use arrows to reorder",
+ options: [
+ { id: "1", label: "Feature A" },
+ { id: "2", label: "Feature B" },
+ { id: "3", label: "Feature C" },
+ { id: "4", label: "Feature D" },
+ { id: "5", label: "Feature E" },
+ { id: "6", label: "Feature F" },
+ { id: "7", label: "Feature G" },
+ { id: "8", label: "Feature H" },
+ ],
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ elementId: "ranking-rtl",
+ inputId: "ranking-input-rtl",
+ dir: "rtl",
+ headline: "رتب هذه العناصر حسب الأهمية",
+ description: "انقر على العناصر لإضافتها إلى الترتيب، ثم استخدم الأسهم لإعادة الترتيب",
+ options: [
+ { id: "1", label: "الخيار الأول" },
+ { id: "2", label: "الخيار الثاني" },
+ { id: "3", label: "الخيار الثالث" },
+ { id: "4", label: "الخيار الرابع" },
+ ],
+ },
+};
+
+export const RTLWithRanking: Story = {
+ args: {
+ elementId: "ranking-rtl-ranked",
+ dir: "rtl",
+ inputId: "ranking-input-rtl-ranked",
+ headline: "رتب هذه العناصر حسب الأهمية",
+ options: [
+ { id: "1", label: "الخيار الأول" },
+ { id: "2", label: "الخيار الثاني" },
+ { id: "3", label: "الخيار الثالث" },
+ { id: "4", label: "الخيار الرابع" },
+ ],
+ value: ["3", "1", "4"],
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => (
+
+ {}}
+ />
+ {}}
+ />
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/elements/ranking.tsx b/packages/survey-ui/src/components/elements/ranking.tsx
new file mode 100644
index 0000000000..fa814e7611
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/ranking.tsx
@@ -0,0 +1,263 @@
+import { useAutoAnimate } from "@formkit/auto-animate/react";
+import { ChevronDown, ChevronUp } from "lucide-react";
+import * as React from "react";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { cn } from "@/lib/utils";
+
+/**
+ * Text direction type for ranking element
+ */
+type TextDirection = "ltr" | "rtl" | "auto";
+
+/**
+ * Option for ranking element
+ */
+export interface RankingOption {
+ /** Unique identifier for the option */
+ id: string;
+ /** Display label for the option */
+ label: string;
+}
+
+interface RankingProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the ranking group */
+ inputId: string;
+ /** Array of options to rank */
+ options: RankingOption[];
+ /** Currently ranked option IDs in order (array of option IDs) */
+ value?: string[];
+ /** Callback function called when ranking changes */
+ onChange: (value: string[]) => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: TextDirection;
+ /** Whether the controls are disabled */
+ disabled?: boolean;
+}
+
+interface RankingItemProps {
+ item: RankingOption;
+ rankedIds: string[];
+ onItemClick: (item: RankingOption) => void;
+ onMove: (itemId: string, direction: "up" | "down") => void;
+ disabled: boolean;
+ 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, onItemClick, onMove, disabled, dir }: Readonly) {
+ const isRanked = rankedIds.includes(item.id);
+ const rankIndex = rankedIds.indexOf(item.id);
+ const isFirst = isRanked && rankIndex === 0;
+ 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 (
+
+
{
+ onItemClick(item);
+ }}
+ disabled={disabled}
+ onKeyDown={(e) => {
+ if (disabled) return;
+ if (e.key === " " || e.key === "Enter") {
+ e.preventDefault();
+ onItemClick(item);
+ }
+ }}
+ className="group flex h-full grow items-center gap-4 text-start focus:outline-none"
+ aria-label={isRanked ? `Remove ${item.label} from ranking` : `Add ${item.label} to ranking`}>
+
+ {displayNumber}
+
+
+ {item.label}
+
+
+
+ {/* Up/Down buttons for ranked items */}
+ {isRanked ? (
+
+ {
+ e.preventDefault();
+ onMove(item.id, "up");
+ }}
+ disabled={isFirst || disabled}
+ aria-label={`Move ${item.label} up`}
+ className={cn(
+ "flex flex-1 items-center justify-center px-2 transition-colors",
+ topButtonRadiusClass
+ )}>
+
+
+ {
+ e.preventDefault();
+ onMove(item.id, "down");
+ }}
+ 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
+ )}>
+
+
+
+ ) : null}
+
+ );
+}
+
+function Ranking({
+ elementId,
+ headline,
+ description,
+ inputId,
+ options,
+ value = [],
+ onChange,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+}: Readonly): React.JSX.Element {
+ // Ensure value is always an array
+ const rankedIds = React.useMemo(() => (Array.isArray(value) ? value : []), [value]);
+
+ // Get sorted (ranked) items and unsorted items
+ const sortedItems = React.useMemo(() => {
+ return rankedIds
+ .map((id) => options.find((opt) => opt.id === id))
+ .filter((item): item is RankingOption => item !== undefined);
+ }, [rankedIds, options]);
+
+ const unsortedItems = React.useMemo(() => {
+ return options.filter((opt) => !rankedIds.includes(opt.id));
+ }, [options, rankedIds]);
+
+ // Handle item click (add to ranking or remove from ranking)
+ const handleItemClick = (item: RankingOption): void => {
+ if (disabled) return;
+
+ const isAlreadyRanked = rankedIds.includes(item.id);
+ const newRankedIds = isAlreadyRanked ? rankedIds.filter((id) => id !== item.id) : [...rankedIds, item.id];
+
+ onChange(newRankedIds);
+ };
+
+ // Handle move up/down
+ const handleMove = (itemId: string, direction: "up" | "down"): void => {
+ if (disabled) return;
+
+ const index = rankedIds.indexOf(itemId);
+ if (index === -1) return;
+
+ const newRankedIds = [...rankedIds];
+ const [movedItem] = newRankedIds.splice(index, 1);
+ const newIndex = direction === "up" ? Math.max(0, index - 1) : Math.min(newRankedIds.length, index + 1);
+ newRankedIds.splice(newIndex, 0, movedItem);
+
+ onChange(newRankedIds);
+ };
+
+ // Combine sorted and unsorted items for display
+ const allItems = [...sortedItems, ...unsortedItems];
+
+ // Animation ref for smooth transitions
+ const [parent] = useAutoAnimate();
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Ranking Options */}
+
+
+
+
+ Ranking options
+ }>
+ {allItems.map((item) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export { Ranking };
+export type { RankingProps };
diff --git a/packages/survey-ui/src/components/elements/rating.stories.tsx b/packages/survey-ui/src/components/elements/rating.stories.tsx
new file mode 100644
index 0000000000..dd07193231
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/rating.stories.tsx
@@ -0,0 +1,320 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import * as React from "react";
+import {
+ type BaseStylingOptions,
+ type LabelStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ createStatefulRender,
+ elementStylingArgTypes,
+ inputStylingArgTypes,
+ labelStylingArgTypes,
+ pickArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { Rating, type RatingProps } from "./rating";
+
+type StoryProps = RatingProps & Partial & Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/Rating",
+ component: Rating,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A rating element that supports number, star, and smiley scales. Users can select a rating from 1 to the specified range.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ scale: {
+ control: { type: "select" },
+ options: ["number", "star", "smiley"],
+ description: "Rating scale type",
+ table: { category: "Content" },
+ },
+ range: {
+ control: { type: "select" },
+ options: [3, 4, 5, 6, 7, 10],
+ description: "Number of rating options",
+ table: { category: "Content" },
+ },
+ value: {
+ control: { type: "number", min: 1 },
+ description: "Currently selected rating value",
+ table: { category: "State" },
+ },
+ lowerLabel: {
+ control: "text",
+ description: "Label for the lower end of the scale",
+ table: { category: "Content" },
+ },
+ upperLabel: {
+ control: "text",
+ description: "Label for the upper end of the scale",
+ table: { category: "Content" },
+ },
+ colorCoding: {
+ control: "boolean",
+ description: "Whether color coding is enabled (for smiley scale)",
+ table: { category: "Content" },
+ },
+ },
+ render: createStatefulRender(Rating),
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const StylingPlayground: Story = {
+ args: {
+ elementId: "rating-1",
+ inputId: "rating-input-1",
+ headline: "How satisfied are you?",
+ description: "Please rate your experience",
+ scale: "number",
+ range: 5,
+ lowerLabel: "Not satisfied",
+ upperLabel: "Very satisfied",
+ elementHeadlineFontFamily: "system-ui, sans-serif",
+ elementHeadlineFontSize: "1.125rem",
+ elementHeadlineFontWeight: "600",
+ elementHeadlineColor: "#1e293b",
+ elementDescriptionFontFamily: "system-ui, sans-serif",
+ elementDescriptionFontSize: "0.875rem",
+ elementDescriptionFontWeight: "400",
+ elementDescriptionColor: "#64748b",
+ labelFontFamily: "system-ui, sans-serif",
+ labelFontSize: "0.75rem",
+ labelFontWeight: "400",
+ labelColor: "#64748b",
+ labelOpacity: "1",
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...labelStylingArgTypes,
+ ...pickArgTypes(inputStylingArgTypes, [
+ "inputBgColor",
+ "inputBorderColor",
+ "inputColor",
+ "inputFontWeight",
+ "inputBorderRadius",
+ ]),
+ ...surveyStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ elementId: "rating-1",
+ inputId: "rating-input-1",
+ headline: "How satisfied are you?",
+ scale: "number",
+ range: 5,
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ elementId: "rating-2",
+ inputId: "rating-input-2",
+ headline: "How satisfied are you?",
+ description: "Please rate your experience from 1 to 5",
+ scale: "number",
+ range: 5,
+ },
+};
+
+export const NumberScale: Story = {
+ args: {
+ elementId: "rating-number",
+ inputId: "rating-input-number",
+ headline: "Rate your experience",
+ scale: "number",
+ range: 5,
+ },
+};
+
+export const StarScale: Story = {
+ args: {
+ elementId: "rating-star",
+ inputId: "rating-input-star",
+ headline: "Rate this product",
+ scale: "star",
+ range: 5,
+ },
+};
+
+export const SmileyScale: Story = {
+ args: {
+ elementId: "rating-smiley",
+ inputId: "rating-input-smiley",
+ headline: "How do you feel?",
+ scale: "smiley",
+ range: 5,
+ },
+};
+
+export const WithLabels: Story = {
+ args: {
+ elementId: "rating-labels",
+ inputId: "rating-input-labels",
+ headline: "Rate your experience",
+ scale: "number",
+ range: 5,
+ lowerLabel: "Not satisfied",
+ upperLabel: "Very satisfied",
+ },
+};
+
+export const WithSelection: Story = {
+ args: {
+ elementId: "rating-selection",
+ inputId: "rating-input-selection",
+ headline: "Rate your experience",
+ scale: "number",
+ range: 5,
+ value: 4,
+ },
+};
+
+export const Required: Story = {
+ args: {
+ elementId: "rating-required",
+ inputId: "rating-input-required",
+ headline: "Rate your experience",
+ scale: "number",
+ range: 5,
+ required: true,
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ elementId: "rating-error",
+ inputId: "rating-input-error",
+ headline: "Rate your experience",
+ scale: "number",
+ range: 5,
+ required: true,
+ errorMessage: "Please select a rating",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ elementId: "rating-disabled",
+ inputId: "rating-input-disabled",
+ headline: "Rate your experience",
+ scale: "number",
+ range: 5,
+ value: 3,
+ disabled: true,
+ },
+};
+
+export const Range3: Story = {
+ args: {
+ elementId: "rating-range3",
+ inputId: "rating-input-range3",
+ headline: "Rate your experience",
+ scale: "number",
+ range: 3,
+ },
+};
+
+export const Range10: Story = {
+ args: {
+ elementId: "rating-range10",
+ inputId: "rating-input-range10",
+ headline: "Rate your experience",
+ scale: "number",
+ range: 10,
+ },
+};
+
+export const ColorCoding: Story = {
+ args: {
+ elementId: "rating-color",
+ inputId: "rating-input-color",
+ headline: "How do you feel?",
+ scale: "smiley",
+ range: 5,
+ colorCoding: true,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ elementId: "rating-rtl",
+ dir: "rtl",
+ inputId: "rating-input-rtl",
+ headline: "كيف تقيم تجربتك؟",
+ description: "يرجى تقييم تجربتك من 1 إلى 5",
+ scale: "number",
+ range: 5,
+ lowerLabel: "غير راض",
+ upperLabel: "راض جداً",
+ },
+};
+
+export const RTLWithSelection: Story = {
+ args: {
+ elementId: "rating-rtl-selection",
+ dir: "rtl",
+ inputId: "rating-input-rtl-selection",
+ headline: "كيف تقيم تجربتك؟",
+ scale: "star",
+ range: 5,
+ value: 4,
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => {
+ const [value1, setValue1] = React.useState(undefined);
+ const [value2, setValue2] = React.useState(undefined);
+ const [value3, setValue3] = React.useState(undefined);
+
+ return (
+
+
+
+
+
+ );
+ },
+};
diff --git a/packages/survey-ui/src/components/elements/rating.tsx b/packages/survey-ui/src/components/elements/rating.tsx
new file mode 100644
index 0000000000..90feda8ea5
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/rating.tsx
@@ -0,0 +1,442 @@
+import { Star } from "lucide-react";
+import * as React from "react";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { Label } from "@/components/general/label";
+import {
+ ConfusedFace,
+ FrowningFace,
+ GrinningFaceWithSmilingEyes,
+ GrinningSquintingFace,
+ NeutralFace,
+ PerseveringFace,
+ SlightlySmilingFace,
+ SmilingFaceWithSmilingEyes,
+ TiredFace,
+ WearyFace,
+} from "@/components/general/smileys";
+import { cn } from "@/lib/utils";
+
+/**
+ * Get smiley color class based on range and index
+ */
+const getSmileyColor = (range: number, idx: number): string => {
+ if (range > 5) {
+ if (range - idx < 3) return "fill-emerald-100";
+ if (range - idx < 5) return "fill-orange-100";
+ return "fill-rose-100";
+ } else if (range < 5) {
+ if (range - idx < 2) return "fill-emerald-100";
+ if (range - idx < 3) return "fill-orange-100";
+ return "fill-rose-100";
+ }
+ if (range - idx < 3) return "fill-emerald-100";
+ if (range - idx < 4) return "fill-orange-100";
+ return "fill-rose-100";
+};
+
+/**
+ * Get active smiley color class based on range and index
+ */
+const getActiveSmileyColor = (range: number, idx: number): string => {
+ if (range > 5) {
+ if (range - idx < 3) return "fill-emerald-300";
+ if (range - idx < 5) return "fill-orange-300";
+ return "fill-rose-300";
+ } else if (range < 5) {
+ if (range - idx < 2) return "fill-emerald-300";
+ if (range - idx < 3) return "fill-orange-300";
+ return "fill-rose-300";
+ }
+ if (range - idx < 3) return "fill-emerald-300";
+ if (range - idx < 4) return "fill-orange-300";
+ return "fill-rose-300";
+};
+
+/**
+ * Get the appropriate smiley icon based on range and index
+ */
+const getSmileyIcon = (
+ iconIdx: number,
+ idx: number,
+ range: number,
+ active: boolean,
+ addColors: boolean
+): React.JSX.Element => {
+ const activeColor = addColors ? getActiveSmileyColor(range, idx) : "fill-yellow-200";
+ const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none";
+
+ const icons = [
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ];
+ return icons[iconIdx];
+};
+
+/**
+ * Smiley component for rating scale
+ */
+const RatingSmiley = ({
+ active,
+ idx,
+ range,
+ addColors = false,
+}: {
+ active: boolean;
+ idx: number;
+ range: number;
+ addColors?: boolean;
+}): React.JSX.Element => {
+ let iconsIdx: number[] = [];
+ if (range === 10) iconsIdx = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
+ else if (range === 7) iconsIdx = [1, 3, 4, 5, 6, 8, 9];
+ else if (range === 6) iconsIdx = [0, 2, 4, 5, 7, 9];
+ else if (range === 5) iconsIdx = [3, 4, 5, 6, 7];
+ else if (range === 4) iconsIdx = [4, 5, 6, 7];
+ else if (range === 3) iconsIdx = [4, 5, 7];
+
+ return getSmileyIcon(iconsIdx[idx], idx, range, active, addColors);
+};
+
+interface RatingProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the rating group */
+ inputId: string;
+ /** Rating scale type: 'number', 'star', or 'smiley' */
+ scale: "number" | "star" | "smiley";
+ /** Number of rating options (3, 4, 5, 6, 7, or 10) */
+ range: 3 | 4 | 5 | 6 | 7 | 10;
+ /** Currently selected rating value (1 to range) */
+ value?: number;
+ /** Callback function called when rating changes */
+ onChange: (value: number) => void;
+ /** Optional label for the lower end of the scale */
+ lowerLabel?: string;
+ /** Optional label for the upper end of the scale */
+ upperLabel?: string;
+ /** Whether color coding is enabled (for smiley scale) */
+ colorCoding?: boolean;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the controls are disabled */
+ disabled?: boolean;
+}
+
+function Rating({
+ elementId,
+ headline,
+ description,
+ inputId,
+ scale,
+ range,
+ value,
+ onChange,
+ lowerLabel,
+ upperLabel,
+ colorCoding = false,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+}: Readonly): React.JSX.Element {
+ const [hoveredValue, setHoveredValue] = React.useState(null);
+
+ // Ensure value is within valid range
+ const currentValue = value && value >= 1 && value <= range ? value : undefined;
+
+ // Handle rating selection
+ const handleSelect = (ratingValue: number): void => {
+ if (!disabled) {
+ onChange(ratingValue);
+ }
+ };
+
+ // Handle keyboard navigation
+ const handleKeyDown = (ratingValue: number) => (e: React.KeyboardEvent) => {
+ if (disabled) return;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleSelect(ratingValue);
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
+ e.preventDefault();
+ const direction = e.key === "ArrowLeft" ? -1 : 1;
+ const newValue = Math.max(1, Math.min(range, (currentValue ?? 1) + direction));
+ handleSelect(newValue);
+ }
+ };
+
+ // Get number option color for color coding
+ const getRatingNumberOptionColor = (ratingRange: number, idx: number): string => {
+ if (ratingRange > 5) {
+ if (ratingRange - idx < 2) return "bg-emerald-100";
+ if (ratingRange - idx < 4) return "bg-orange-100";
+ return "bg-rose-100";
+ } else if (ratingRange < 5) {
+ if (ratingRange - idx < 1) return "bg-emerald-100";
+ if (ratingRange - idx < 2) return "bg-orange-100";
+ return "bg-rose-100";
+ }
+ if (ratingRange - idx < 2) return "bg-emerald-100";
+ if (ratingRange - idx < 3) return "bg-orange-100";
+ return "bg-rose-100";
+ };
+
+ // Render number scale option
+ const renderNumberOption = (number: number, totalLength: number): React.JSX.Element => {
+ const isSelected = currentValue === number;
+ const isHovered = hoveredValue === number;
+ const isLast = totalLength === number;
+ const isFirst = number === 1;
+
+ // Determine border radius and border classes
+ // Use right border for all items to create separators, left border only on first item
+ let borderRadiusClasses = "";
+ let borderClasses = "border-t border-b border-r";
+
+ if (isFirst) {
+ borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input";
+ borderClasses = "border-t border-b border-l border-r";
+ } else if (isLast) {
+ borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input";
+ // Last item keeps right border for rounded corner
+ }
+
+ return (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
+ {
+ if (!disabled) {
+ setHoveredValue(number);
+ }
+ }}
+ onMouseLeave={() => {
+ setHoveredValue(null);
+ }}
+ onFocus={() => {
+ if (!disabled) {
+ setHoveredValue(number);
+ }
+ }}
+ onBlur={() => {
+ setHoveredValue(null);
+ }}>
+ {colorCoding ? (
+
+ ) : null}
+ {
+ handleSelect(number);
+ }}
+ disabled={disabled}
+ required={required}
+ className="sr-only"
+ aria-label={`Rate ${String(number)} out of ${String(range)}`}
+ />
+ {number}
+
+ );
+ };
+
+ // Render star scale option
+ const renderStarOption = (number: number): React.JSX.Element => {
+ const isSelected = currentValue === number;
+ // Fill all stars up to the hovered value (if hovering) or selected value
+ const activeValue = hoveredValue ?? currentValue ?? 0;
+ const isActive = number <= activeValue;
+
+ return (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
+ {
+ if (!disabled) {
+ setHoveredValue(number);
+ }
+ }}
+ onMouseLeave={() => {
+ setHoveredValue(null);
+ }}
+ onFocus={() => {
+ if (!disabled) {
+ setHoveredValue(number);
+ }
+ }}
+ onBlur={() => {
+ setHoveredValue(null);
+ }}
+ tabIndex={disabled ? -1 : 0}
+ onKeyDown={handleKeyDown(number)}>
+ {
+ handleSelect(number);
+ }}
+ disabled={disabled}
+ required={required}
+ className="sr-only"
+ aria-label={`Rate ${String(number)} out of ${String(range)} stars`}
+ />
+
+ {isActive ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ };
+
+ // Render smiley scale option
+ const renderSmileyOption = (number: number, index: number): React.JSX.Element => {
+ const isSelected = currentValue === number;
+ const isHovered = hoveredValue === number;
+ const isActive = isSelected || isHovered;
+
+ return (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
+ {
+ if (!disabled) {
+ setHoveredValue(number);
+ }
+ }}
+ onMouseLeave={() => {
+ setHoveredValue(null);
+ }}
+ onFocus={() => {
+ if (!disabled) {
+ setHoveredValue(number);
+ }
+ }}
+ onBlur={() => {
+ setHoveredValue(null);
+ }}>
+ {
+ handleSelect(number);
+ }}
+ disabled={disabled}
+ required={required}
+ className="sr-only"
+ aria-label={`Rate ${String(number)} out of ${String(range)}`}
+ />
+
+
+
+
+ );
+ };
+
+ // Generate rating options
+ const ratingOptions = Array.from({ length: range }, (_, i) => i + 1);
+
+ return (
+
+ {/* Headline */}
+
+
+ {/* Rating Options */}
+
+
+
+ Rating options
+
+ {ratingOptions.map((number, index) => {
+ if (scale === "number") {
+ return renderNumberOption(number, ratingOptions.length);
+ } else if (scale === "star") {
+ return renderStarOption(number);
+ }
+ return renderSmileyOption(number, index);
+ })}
+
+
+ {/* Labels */}
+ {(lowerLabel ?? upperLabel) ? (
+
+ {lowerLabel ? (
+
+ {lowerLabel}
+
+ ) : null}
+ {upperLabel ? (
+
+ {upperLabel}
+
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+}
+
+export { Rating };
+export type { RatingProps };
diff --git a/packages/survey-ui/src/components/elements/single-select.stories.tsx b/packages/survey-ui/src/components/elements/single-select.stories.tsx
new file mode 100644
index 0000000000..344be9e516
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/single-select.stories.tsx
@@ -0,0 +1,382 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import * as React from "react";
+import { useEffect, useState } from "react";
+import {
+ type BaseStylingOptions,
+ type LabelStylingOptions,
+ type OptionStylingOptions,
+ commonArgTypes,
+ createCSSVariablesDecorator,
+ elementStylingArgTypes,
+ inputStylingArgTypes,
+ labelStylingArgTypes,
+ optionStylingArgTypes,
+ surveyStylingArgTypes,
+} from "../../lib/story-helpers";
+import { SingleSelect, type SingleSelectOption, type SingleSelectProps } from "./single-select";
+
+type StoryProps = SingleSelectProps &
+ Partial &
+ Record;
+
+const meta: Meta = {
+ title: "UI-package/Elements/SingleSelect",
+ component: SingleSelect,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A complete single-select element that combines headline, description, and radio button options. Supports single selection, validation, and RTL text direction.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ ...commonArgTypes,
+ options: {
+ control: "object",
+ description: "Array of options to choose from",
+ table: { category: "Content" },
+ },
+ value: {
+ control: "text",
+ description: "Selected option ID",
+ table: { category: "State" },
+ },
+ variant: {
+ control: { type: "select" },
+ options: ["list", "dropdown"],
+ description: "Display variant: 'list' shows radio buttons, 'dropdown' shows a dropdown menu",
+ table: { category: "Layout" },
+ },
+ placeholder: {
+ control: "text",
+ description: "Placeholder text for dropdown button when no option is selected",
+ table: { category: "Content" },
+ },
+ },
+ render: function Render(args: StoryProps) {
+ const [value, setValue] = useState(args.value);
+ const [otherValue, setOtherValue] = useState(args.otherValue);
+ const handleOtherValueChange = (v: string) => {
+ setOtherValue(v);
+ args.onOtherValueChange?.(v);
+ };
+
+ useEffect(() => {
+ setValue(args.value);
+ }, [args.value]);
+
+ return (
+ {
+ setValue(v);
+ args.onChange?.(v);
+ }}
+ otherValue={otherValue}
+ onOtherValueChange={handleOtherValueChange}
+ />
+ );
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const defaultOptions: SingleSelectOption[] = [
+ { id: "option-1", label: "Option 1" },
+ { id: "option-2", label: "Option 2" },
+ { id: "option-3", label: "Option 3" },
+ { id: "option-4", label: "Option 4" },
+];
+
+export const StylingPlayground: Story = {
+ args: {
+ headline: "Which option do you prefer?",
+ description: "Select one option",
+ options: defaultOptions,
+ },
+ argTypes: {
+ ...elementStylingArgTypes,
+ ...labelStylingArgTypes,
+ ...optionStylingArgTypes,
+ ...inputStylingArgTypes,
+ ...surveyStylingArgTypes,
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const Default: Story = {
+ args: {
+ headline: "Which option do you prefer?",
+ options: defaultOptions,
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "What is your favorite programming language?",
+ description: "Select the language you use most frequently",
+ options: [
+ { id: "js", label: "JavaScript" },
+ { id: "ts", label: "TypeScript" },
+ { id: "python", label: "Python" },
+ { id: "java", label: "Java" },
+ { id: "go", label: "Go" },
+ { id: "rust", label: "Rust" },
+ ],
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "Select your preferred plan",
+ description: "Please choose one option",
+ options: [
+ { id: "basic", label: "Basic Plan" },
+ { id: "pro", label: "Pro Plan" },
+ { id: "enterprise", label: "Enterprise Plan" },
+ ],
+ required: true,
+ },
+};
+
+export const WithSelection: Story = {
+ args: {
+ headline: "Which option do you prefer?",
+ description: "Select one option",
+ options: defaultOptions,
+ value: "option-2",
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ headline: "Select your preference",
+ description: "Please select an option",
+ options: [
+ { id: "yes", label: "Yes" },
+ { id: "no", label: "No" },
+ { id: "maybe", label: "Maybe" },
+ ],
+ errorMessage: "Please select an option",
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ headline: "This element is disabled",
+ description: "You cannot change the selection",
+ options: defaultOptions,
+ value: "option-2",
+ disabled: true,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ headline: "ما هو خيارك المفضل؟",
+ dir: "rtl",
+ description: "اختر خيارًا واحدًا",
+ options: [
+ { id: "opt-1", label: "الخيار الأول" },
+ { id: "opt-2", label: "الخيار الثاني" },
+ { id: "opt-3", label: "الخيار الثالث" },
+ { id: "opt-4", label: "الخيار الرابع" },
+ ],
+ },
+};
+
+export const RTLWithSelection: Story = {
+ args: {
+ headline: "ما هو تفضيلك؟",
+ dir: "rtl",
+ description: "يرجى اختيار خيار واحد",
+ options: [
+ { id: "tech", label: "التكنولوجيا" },
+ { id: "design", label: "التصميم" },
+ { id: "marketing", label: "التسويق" },
+ { id: "sales", label: "المبيعات" },
+ ],
+ value: "tech",
+ },
+};
+
+export const MultipleElements: Story = {
+ render: () => {
+ const [value1, setValue1] = React.useState(undefined);
+ const [value2, setValue2] = React.useState("js");
+
+ return (
+
+
+
+
+ );
+ },
+};
+
+export const Dropdown: Story = {
+ args: {
+ headline: "Which option do you prefer?",
+ description: "Select one option",
+ options: defaultOptions,
+ variant: "dropdown",
+ placeholder: "Choose an option...",
+ },
+};
+
+export const DropdownWithSelection: Story = {
+ args: {
+ headline: "Which option do you prefer?",
+ description: "Select one option",
+ options: defaultOptions,
+ value: "option-2",
+ variant: "dropdown",
+ placeholder: "Choose an option...",
+ },
+};
+
+export const WithOtherOption: Story = {
+ render: () => {
+ const [value, setValue] = React.useState(undefined);
+ const [otherValue, setOtherValue] = React.useState("");
+
+ return (
+
+
+
+ );
+ },
+};
+
+export const WithOtherOptionSelected: Story = {
+ render: () => {
+ const [value, setValue] = React.useState("other");
+ const [otherValue, setOtherValue] = React.useState("Custom option");
+
+ return (
+
+
+
+ );
+ },
+};
+
+export const DropdownWithOtherOption: Story = {
+ render: () => {
+ const [value, setValue] = React.useState(undefined);
+ const [otherValue, setOtherValue] = React.useState("");
+
+ return (
+
+
+
+ );
+ },
+};
+
+export const WithContainerStyling: Story = {
+ args: {
+ headline: "Select your preferred option",
+ description: "Each option has a container with custom styling",
+ options: [
+ { id: "option-1", label: "Option 1" },
+ { id: "option-2", label: "Option 2" },
+ { id: "option-3", label: "Option 3" },
+ { id: "option-4", label: "Option 4" },
+ ],
+ value: "option-2",
+ },
+ decorators: [createCSSVariablesDecorator()],
+};
+
+export const WithContainerStylingAndOther: Story = {
+ render: () => {
+ const [value, setValue] = React.useState(undefined);
+ const [otherValue, setOtherValue] = React.useState("");
+
+ return (
+
+
+
+ );
+ },
+};
diff --git a/packages/survey-ui/src/components/elements/single-select.tsx b/packages/survey-ui/src/components/elements/single-select.tsx
new file mode 100644
index 0000000000..d6dcfd8656
--- /dev/null
+++ b/packages/survey-ui/src/components/elements/single-select.tsx
@@ -0,0 +1,315 @@
+import { ChevronDown } from "lucide-react";
+import * as React from "react";
+import { Button } from "@/components/general/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/general/dropdown-menu";
+import { ElementError } from "@/components/general/element-error";
+import { ElementHeader } from "@/components/general/element-header";
+import { Input } from "@/components/general/input";
+import { RadioGroup, RadioGroupItem } from "@/components/general/radio-group";
+import { cn } from "@/lib/utils";
+
+/**
+ * Option for single-select element
+ */
+export interface SingleSelectOption {
+ /** Unique identifier for the option */
+ id: string;
+ /** Display label for the option */
+ label: string;
+}
+
+interface SingleSelectProps {
+ /** Unique identifier for the element container */
+ elementId: string;
+ /** The main element or prompt text displayed as the headline */
+ headline: string;
+ /** Optional descriptive text displayed below the headline */
+ description?: string;
+ /** Unique identifier for the single-select group */
+ inputId: string;
+ /** Array of options to choose from */
+ options: SingleSelectOption[];
+ /** Currently selected option ID */
+ value?: string;
+ /** Callback function called when selection changes */
+ onChange: (value: string) => void;
+ /** Whether the field is required (shows asterisk indicator) */
+ required?: boolean;
+ /** Error message to display below the options */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Whether the options are disabled */
+ disabled?: boolean;
+ /** Display variant: 'list' shows radio buttons, 'dropdown' shows a dropdown menu */
+ variant?: "list" | "dropdown";
+ /** Placeholder text for dropdown button when no option is selected */
+ placeholder?: string;
+ /** ID for the 'other' option that allows custom input */
+ otherOptionId?: string;
+ /** Label for the 'other' option */
+ otherOptionLabel?: string;
+ /** Placeholder text for the 'other' input field */
+ otherOptionPlaceholder?: string;
+ /** Custom value entered in the 'other' input field */
+ otherValue?: string;
+ /** Callback when the 'other' input value changes */
+ onOtherValueChange?: (value: string) => void;
+}
+
+function SingleSelect({
+ elementId,
+ headline,
+ description,
+ inputId,
+ options,
+ value,
+ onChange,
+ required = false,
+ errorMessage,
+ dir = "auto",
+ disabled = false,
+ variant = "list",
+ placeholder = "Select an option...",
+ otherOptionId,
+ otherOptionLabel = "Other",
+ otherOptionPlaceholder = "Please specify",
+ otherValue = "",
+ onOtherValueChange,
+}: Readonly): React.JSX.Element {
+ // Ensure value is always a string or undefined
+ const selectedValue = value ?? undefined;
+ const hasOtherOption = Boolean(otherOptionId);
+ const isOtherSelected = hasOtherOption && selectedValue === otherOptionId;
+ const otherInputRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!isOtherSelected || disabled) return;
+
+ // Delay focus to win against Radix focus restoration when dropdown closes / radio item receives focus.
+ const timeoutId = globalThis.setTimeout(() => {
+ globalThis.requestAnimationFrame(() => {
+ otherInputRef.current?.focus();
+ });
+ }, 0);
+
+ return () => {
+ globalThis.clearTimeout(timeoutId);
+ };
+ }, [isOtherSelected, disabled, variant]);
+
+ const handleOtherInputChange = (e: React.ChangeEvent): void => {
+ onOtherValueChange?.(e.target.value);
+ };
+
+ // Shared className for option containers
+ const getOptionContainerClassName = (isSelected: boolean): string =>
+ cn(
+ "relative flex cursor-pointer flex-col border transition-colors outline-none",
+ "rounded-option px-option-x py-option-y",
+ isSelected ? "bg-option-selected-bg border-brand" : "bg-option-bg border-option-border",
+ "focus-within:border-brand focus-within:bg-option-selected-bg",
+ "hover:bg-option-hover-bg",
+ disabled && "cursor-not-allowed opacity-50"
+ );
+
+ // Shared className for option labels
+ const optionLabelClassName = "font-option font-option-weight text-option-label";
+
+ // Get selected option label for dropdown display
+ const selectedOption = options.find((opt) => opt.id === selectedValue);
+ const displayText = isOtherSelected
+ ? otherValue || otherOptionLabel
+ : (selectedOption?.label ?? placeholder);
+
+ return (
+
+ );
+}
+
+export { SingleSelect };
+export type { SingleSelectProps };
diff --git a/packages/survey-ui/src/components/general/alert.stories.tsx b/packages/survey-ui/src/components/general/alert.stories.tsx
new file mode 100644
index 0000000000..4aaabf963b
--- /dev/null
+++ b/packages/survey-ui/src/components/general/alert.stories.tsx
@@ -0,0 +1,66 @@
+import { type Meta, type StoryObj } from "@storybook/react";
+import { TriangleAlertIcon } from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "./alert";
+
+const meta: Meta = {
+ title: "UI-package/General/Alert",
+ component: Alert,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ variant: {
+ control: "select",
+ options: ["default", "destructive"],
+ description: "Style variant of the alert",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+ Alert Title
+ This is a default alert message.
+
+ ),
+};
+
+export const Destructive: Story = {
+ render: () => (
+
+ Error
+ Something went wrong. Please try again.
+
+ ),
+};
+
+export const DestructiveWithIcon: Story = {
+ render: () => (
+
+
+ Error
+ Something went wrong. Please try again.
+
+ ),
+};
+
+export const WithTitleOnly: Story = {
+ render: () => (
+
+ Important Notice
+
+ ),
+};
+
+export const WithDescriptionOnly: Story = {
+ render: () => (
+
+ This alert only has a description.
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/general/alert.tsx b/packages/survey-ui/src/components/general/alert.tsx
new file mode 100644
index 0000000000..3ec85031ff
--- /dev/null
+++ b/packages/survey-ui/src/components/general/alert.tsx
@@ -0,0 +1,54 @@
+import { type VariantProps, cva } from "class-variance-authority";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps): React.JSX.Element {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element {
+ return (
+
+ );
+}
+
+function AlertDescription({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/packages/survey-ui/src/components/general/button.stories.tsx b/packages/survey-ui/src/components/general/button.stories.tsx
new file mode 100644
index 0000000000..4b6e544328
--- /dev/null
+++ b/packages/survey-ui/src/components/general/button.stories.tsx
@@ -0,0 +1,243 @@
+import type { Decorator, Meta, StoryContext, StoryObj } from "@storybook/react";
+import React from "react";
+import { Button } from "./button";
+
+// Styling options for the StylingPlayground story
+interface StylingOptions {
+ buttonHeight: string;
+ buttonWidth: string;
+ buttonFontSize: string;
+ buttonFontFamily: string;
+ buttonFontWeight: string;
+ buttonBorderRadius: string;
+ buttonBgColor: string;
+ buttonTextColor: string;
+ buttonPaddingX: string;
+ buttonPaddingY: string;
+}
+
+type ButtonProps = React.ComponentProps;
+type StoryProps = ButtonProps & StylingOptions;
+
+const meta: Meta = {
+ title: "UI-package/General/Button",
+ component: Button,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ variant: {
+ control: "select",
+ options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"],
+ description: "Visual style variant of the button",
+ table: { category: "Component Props" },
+ },
+ size: {
+ control: "select",
+ options: ["default", "sm", "lg", "icon"],
+ description: "Size of the button",
+ table: { category: "Component Props" },
+ },
+ disabled: {
+ control: "boolean",
+ table: { category: "Component Props" },
+ },
+ asChild: {
+ table: { disable: true },
+ },
+ children: {
+ table: { disable: true },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+const withCSSVariables: Decorator = (
+ Story: React.ComponentType,
+ context: StoryContext
+) => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
+ const args = context.args as StoryProps;
+ const {
+ buttonHeight,
+ buttonWidth,
+ buttonFontSize,
+ buttonFontFamily,
+ buttonFontWeight,
+ buttonBorderRadius,
+ buttonBgColor,
+ buttonTextColor,
+ buttonPaddingX,
+ buttonPaddingY,
+ } = args;
+
+ const cssVarStyle: React.CSSProperties & Record = {
+ "--fb-button-height": buttonHeight,
+ "--fb-button-width": buttonWidth,
+ "--fb-button-font-size": buttonFontSize,
+ "--fb-button-font-family": buttonFontFamily,
+ "--fb-button-font-weight": buttonFontWeight,
+ "--fb-button-border-radius": buttonBorderRadius,
+ "--fb-button-bg-color": buttonBgColor,
+ "--fb-button-text-color": buttonTextColor,
+ "--fb-button-padding-x": buttonPaddingX,
+ "--fb-button-padding-y": buttonPaddingY,
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const StylingPlayground: Story = {
+ args: {
+ variant: "custom",
+ children: "Custom Button",
+ },
+ argTypes: {
+ // Button Styling (CSS Variables) - Only for this story
+ buttonHeight: {
+ control: "text",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonWidth: {
+ control: "text",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonFontSize: {
+ control: "text",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonFontFamily: {
+ control: "text",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonFontWeight: {
+ control: "text",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonBorderRadius: {
+ control: "text",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonBgColor: {
+ control: "color",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonTextColor: {
+ control: "color",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonPaddingX: {
+ control: "text",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ buttonPaddingY: {
+ control: "text",
+ table: {
+ category: "Button Styling",
+ },
+ },
+ },
+ decorators: [withCSSVariables],
+};
+
+export const Default: Story = {
+ args: {
+ children: "Button",
+ },
+};
+
+export const Destructive: Story = {
+ args: {
+ variant: "destructive",
+ children: "Delete",
+ },
+};
+
+export const Outline: Story = {
+ args: {
+ variant: "outline",
+ children: "Button",
+ },
+};
+
+export const Secondary: Story = {
+ args: {
+ variant: "secondary",
+ children: "Button",
+ },
+};
+
+export const Ghost: Story = {
+ args: {
+ variant: "ghost",
+ children: "Button",
+ },
+};
+
+export const Link: Story = {
+ args: {
+ variant: "link",
+ children: "Button",
+ },
+};
+
+export const Small: Story = {
+ args: {
+ size: "sm",
+ children: "Small Button",
+ },
+};
+
+export const Large: Story = {
+ args: {
+ size: "lg",
+ children: "Large Button",
+ },
+};
+
+export const Icon: Story = {
+ args: {
+ size: "icon",
+ children: "×",
+ },
+};
+
+export const Custom: Story = {
+ args: {
+ variant: "custom",
+ children: "Custom Button",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ children: "Disabled Button",
+ },
+};
diff --git a/packages/survey-ui/src/components/general/button.tsx b/packages/survey-ui/src/components/general/button.tsx
new file mode 100644
index 0000000000..32ca145d04
--- /dev/null
+++ b/packages/survey-ui/src/components/general/button.tsx
@@ -0,0 +1,58 @@
+import { Slot } from "@radix-ui/react-slot";
+import { type VariantProps, cva } from "class-variance-authority";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ custom: "button-custom",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+ }
+);
+
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/packages/survey-ui/src/components/general/calendar.tsx b/packages/survey-ui/src/components/general/calendar.tsx
new file mode 100644
index 0000000000..7f8aa3d95b
--- /dev/null
+++ b/packages/survey-ui/src/components/general/calendar.tsx
@@ -0,0 +1,218 @@
+import { type Locale, format } from "date-fns";
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import * as React from "react";
+import { type DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
+import { Button, buttonVariants } from "@/components/general/button";
+import { getDateFnsLocale } from "@/lib/locale";
+import { cn } from "@/lib/utils";
+
+// Extracted components to avoid defining during render
+function CalendarRoot({
+ className,
+ rootRef,
+ ...props
+}: Readonly<{
+ className?: string;
+ rootRef?: React.Ref;
+ children?: React.ReactNode;
+}>): React.JSX.Element {
+ return
;
+}
+
+function CalendarChevron({
+ className,
+ orientation,
+ ...props
+}: Readonly<{
+ className?: string;
+ orientation?: "left" | "right" | "up" | "down";
+}>): React.JSX.Element {
+ if (orientation === "left") {
+ return ;
+ }
+
+ if (orientation === "right") {
+ return ;
+ }
+
+ return ;
+}
+
+function CalendarWeekNumber({
+ children,
+ ...props
+}: Readonly<{ children?: React.ReactNode }>): React.JSX.Element {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ locale,
+ ...props
+}: Readonly<
+ React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"];
+ locale?: Locale | string;
+ }
+>): React.JSX.Element {
+ const defaultClassNames = getDefaultClassNames();
+
+ // Resolve locale to Locale object if string is provided
+ const resolvedLocale = React.useMemo(() => {
+ if (!locale) return undefined;
+ if (typeof locale === "string") {
+ return getDateFnsLocale(locale);
+ }
+ return locale;
+ }, [locale]);
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ locale={resolvedLocale}
+ formatters={{
+ formatMonthDropdown: (date) => {
+ if (resolvedLocale) {
+ return format(date, "MMM", { locale: resolvedLocale });
+ }
+ return date.toLocaleString("default", { month: "short" });
+ },
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-[--cell-size] w-[--cell-size] aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "h-[--cell-size] w-[--cell-size] aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-[--cell-size] w-full px-[--cell-size]",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-[--cell-size] gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-ring border border-input-border shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none text-[var(--fb-input-color)] opacity-70",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn("select-none w-[--cell-size]", defaultClassNames.week_number_header),
+ week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+ props.showWeekNumber
+ ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
+ : "[&:first-child[data-selected=true]_button]:rounded-l-md",
+ defaultClassNames.day
+ ),
+ range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-[var(--fb-input-color)] opacity-70 aria-selected:text-[var(--fb-input-color)] opacity-70",
+ defaultClassNames.outside
+ ),
+ disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ // @ts-expect-error - React types version mismatch - the project uses React 19 types, but some Radix UI packages (react-day-picker) bundle their own older React types, creating incompatible Ref type definitions
+ Root: CalendarRoot,
+ Chevron: CalendarChevron,
+ DayButton: CalendarDayButton,
+ WeekNumber: CalendarWeekNumber,
+ ...components,
+ }}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps): React.JSX.Element {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus();
+ }, [modifiers.focused]);
+
+ return (
+ span]:text-xs [&>span]:opacity-70",
+ defaultClassNames.day,
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Calendar, CalendarDayButton };
diff --git a/packages/survey-ui/src/components/general/checkbox.stories.tsx b/packages/survey-ui/src/components/general/checkbox.stories.tsx
new file mode 100644
index 0000000000..5c3a2cb954
--- /dev/null
+++ b/packages/survey-ui/src/components/general/checkbox.stories.tsx
@@ -0,0 +1,90 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Checkbox } from "./checkbox";
+import { Label } from "./label";
+
+const meta: Meta = {
+ title: "UI-package/General/Checkbox",
+ component: Checkbox,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A checkbox component built with Radix UI primitives. Supports checked, unchecked, and indeterminate states with full accessibility support.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ checked: {
+ control: { type: "boolean" },
+ description: "The controlled checked state of the checkbox",
+ },
+ disabled: {
+ control: { type: "boolean" },
+ description: "Whether the checkbox is disabled",
+ },
+ required: {
+ control: { type: "boolean" },
+ description: "Whether the checkbox is required",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ "aria-label": "Checkbox",
+ },
+};
+
+export const Checked: Story = {
+ args: {
+ checked: true,
+ "aria-label": "Checked checkbox",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ "aria-label": "Disabled checkbox",
+ },
+};
+
+export const DisabledChecked: Story = {
+ args: {
+ disabled: true,
+ checked: true,
+ "aria-label": "Disabled checked checkbox",
+ },
+};
+
+export const WithLabel: Story = {
+ render: () => (
+
+
+ Accept terms and conditions
+
+ ),
+};
+
+export const WithLabelChecked: Story = {
+ render: () => (
+
+
+ Accept terms and conditions
+
+ ),
+};
+
+export const WithLabelDisabled: Story = {
+ render: () => (
+
+
+ Accept terms and conditions
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/general/checkbox.tsx b/packages/survey-ui/src/components/general/checkbox.tsx
new file mode 100644
index 0000000000..ed19be7518
--- /dev/null
+++ b/packages/survey-ui/src/components/general/checkbox.tsx
@@ -0,0 +1,27 @@
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { CheckIcon } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps): React.JSX.Element {
+ return (
+
+
+
+
+
+ );
+}
+
+export { Checkbox };
diff --git a/packages/survey-ui/src/components/general/dropdown-menu.stories.tsx b/packages/survey-ui/src/components/general/dropdown-menu.stories.tsx
new file mode 100644
index 0000000000..f042ecf2cc
--- /dev/null
+++ b/packages/survey-ui/src/components/general/dropdown-menu.stories.tsx
@@ -0,0 +1,188 @@
+import { type Meta, type StoryObj } from "@storybook/react";
+import { CreditCardIcon, LogOutIcon, SettingsIcon, UserIcon, UsersIcon } from "lucide-react";
+import * as React from "react";
+import { Button } from "./button";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "./dropdown-menu";
+
+const meta: Meta = {
+ title: "UI-package/General/DropdownMenu",
+ component: DropdownMenu,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+ Open Menu
+
+
+ My Account
+
+
+
+ Profile
+
+
+
+ Billing
+
+
+
+ Settings
+
+
+
+
+ Log out
+
+
+
+ ),
+};
+
+export const WithShortcuts: Story = {
+ render: () => (
+
+
+ Open Menu
+
+
+ My Account
+
+
+
+ Profile
+ ⇧⌘P
+
+
+
+ Billing
+ ⌘B
+
+
+
+ Settings
+ ⌘S
+
+
+
+
+ Log out
+ ⇧⌘Q
+
+
+
+ ),
+};
+
+export const WithCheckboxes: Story = {
+ render: () => {
+ const [showStatusBar, setShowStatusBar] = React.useState(true);
+ const [showActivityBar, setShowActivityBar] = React.useState(false);
+ const [showPanel, setShowPanel] = React.useState(false);
+
+ return (
+
+
+ View Options
+
+
+ Appearance
+
+
+ Status Bar
+
+
+ Activity Bar
+
+
+ Panel
+
+
+
+ );
+ },
+};
+
+export const WithRadioGroup: Story = {
+ render: () => {
+ const [position, setPosition] = React.useState("bottom");
+
+ return (
+
+
+ Position
+
+
+ Panel Position
+
+
+ Top
+ Bottom
+ Right
+
+
+
+ );
+ },
+};
+
+export const WithSubmenu: Story = {
+ render: () => (
+
+
+ Open Menu
+
+
+ My Account
+
+
+
+ Profile
+
+
+
+ Billing
+
+
+
+
+ Invite users
+
+
+ Email
+ Message
+
+ More...
+
+
+
+
+
+ Log out
+
+
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/general/dropdown-menu.tsx b/packages/survey-ui/src/components/general/dropdown-menu.tsx
new file mode 100644
index 0000000000..f1f1fc8096
--- /dev/null
+++ b/packages/survey-ui/src/components/general/dropdown-menu.tsx
@@ -0,0 +1,218 @@
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({ ...props }: Readonly>) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: Readonly>) {
+ return ;
+}
+
+function DropdownMenuTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: Readonly>) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({ ...props }: Readonly>) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/packages/survey-ui/src/components/general/element-error.tsx b/packages/survey-ui/src/components/general/element-error.tsx
new file mode 100644
index 0000000000..38badde2b6
--- /dev/null
+++ b/packages/survey-ui/src/components/general/element-error.tsx
@@ -0,0 +1,36 @@
+import { AlertCircle } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+interface ElementErrorProps {
+ /** Error message to display */
+ errorMessage?: string;
+ /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
+ dir?: "ltr" | "rtl" | "auto";
+}
+
+function ElementError({ errorMessage, dir = "auto" }: Readonly): React.JSX.Element | null {
+ if (!errorMessage) {
+ return null;
+ }
+
+ return (
+ <>
+ {/* Error indicator bar */}
+
+ {/* Error message - shown at top */}
+
+ >
+ );
+}
+
+export { ElementError };
+export type { ElementErrorProps };
diff --git a/packages/survey-ui/src/components/general/element-header.stories.tsx b/packages/survey-ui/src/components/general/element-header.stories.tsx
new file mode 100644
index 0000000000..423cbd3283
--- /dev/null
+++ b/packages/survey-ui/src/components/general/element-header.stories.tsx
@@ -0,0 +1,111 @@
+import { type Meta, type StoryObj } from "@storybook/react";
+import { ElementHeader } from "./element-header";
+
+const meta: Meta = {
+ title: "UI-package/General/ElementHeader",
+ component: ElementHeader,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ headline: {
+ control: "text",
+ description: "The main headline text",
+ },
+ description: {
+ control: "text",
+ description: "Optional description text displayed below the headline",
+ },
+ required: {
+ control: "boolean",
+ description: "Whether the field is required (shows asterisk)",
+ },
+ htmlFor: {
+ control: "text",
+ description: "The id of the form control this header is associated with",
+ },
+ imageUrl: {
+ control: "text",
+ description: "URL of an image to display above the headline",
+ },
+ videoUrl: {
+ control: "text",
+ description: "URL of a video (YouTube, Vimeo, or Loom) to display above the headline",
+ },
+ imageAltText: {
+ control: "text",
+ description: "Alt text for the image",
+ },
+ },
+ args: {
+ headline: "Element Title",
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ headline: "What is your name?",
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ headline: "How satisfied are you?",
+ description: "Please rate your experience from 1 to 10",
+ },
+};
+
+export const Required: Story = {
+ args: {
+ headline: "Email Address",
+ required: true,
+ },
+};
+
+export const RequiredWithDescription: Story = {
+ args: {
+ headline: "Phone Number",
+ description: "We'll use this to contact you about your order",
+ required: true,
+ },
+};
+
+export const WithHtmlFor: Story = {
+ render: () => (
+
+
+
+
+ ),
+};
+
+export const WithImage: Story = {
+ args: {
+ headline: "What do you see in this image?",
+ description: "Please describe what you observe",
+ imageUrl: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop",
+ imageAltText: "Mountain landscape",
+ },
+};
+
+export const WithVideo: Story = {
+ args: {
+ headline: "Watch this video",
+ description: "Please watch the video and answer the questions below",
+ videoUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ },
+};
+
+export const WithImageAndDescription: Story = {
+ args: {
+ headline: "Rate this design",
+ description: "On a scale of 1-10, how would you rate this design?",
+ required: true,
+ imageUrl: "https://images.unsplash.com/photo-1561070791-2526d30994b5?w=800&h=600&fit=crop",
+ imageAltText: "Design mockup",
+ },
+};
diff --git a/packages/survey-ui/src/components/general/element-header.tsx b/packages/survey-ui/src/components/general/element-header.tsx
new file mode 100644
index 0000000000..3e38dcb960
--- /dev/null
+++ b/packages/survey-ui/src/components/general/element-header.tsx
@@ -0,0 +1,102 @@
+import DOMPurify from "isomorphic-dompurify";
+import * as React from "react";
+import { ElementMedia } from "@/components/general/element-media";
+import { Label } from "@/components/general/label";
+import { cn, stripInlineStyles } from "@/lib/utils";
+
+interface ElementHeaderProps extends React.ComponentProps<"div"> {
+ headline: string;
+ description?: string;
+ required?: boolean;
+ htmlFor?: string;
+ imageUrl?: string;
+ videoUrl?: string;
+ imageAltText?: string;
+}
+
+/**
+ * Checks if a string contains valid HTML markup
+ * @param str - The input string to test
+ * @returns true if the string contains valid HTML elements, false otherwise
+ */
+const isValidHTML = (str: string): boolean => {
+ if (!str) return false;
+
+ try {
+ const doc = new DOMParser().parseFromString(str, "text/html");
+ const errorNode = doc.querySelector("parsererror");
+ if (errorNode) return false;
+ return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * Strips inline style attributes to prevent CSP violations
+ * Uses a safe regex pattern to avoid ReDoS (Regular Expression Denial of Service) vulnerabilities
+ * @param html - The HTML string to clean
+ * @returns HTML string without inline style attributes
+ */
+
+function ElementHeader({
+ headline,
+ description,
+ required = false,
+ htmlFor,
+ className,
+ imageUrl,
+ videoUrl,
+ imageAltText,
+ ...props
+}: Readonly): React.JSX.Element {
+ const isMediaAvailable = imageUrl ?? videoUrl;
+
+ // Check if headline is HTML
+ const strippedHeadline = stripInlineStyles(headline);
+ const isHeadlineHtml = isValidHTML(strippedHeadline);
+ const safeHeadlineHtml =
+ isHeadlineHtml && strippedHeadline
+ ? DOMPurify.sanitize(strippedHeadline, {
+ ADD_ATTR: ["target"],
+ FORBID_ATTR: ["style"],
+ })
+ : "";
+
+ return (
+
+ {/* Media (Image or Video) */}
+ {isMediaAvailable ? (
+
+ ) : null}
+
+ {/* Headline */}
+
+
+ {required ? Required : null}
+
+
+ {isHeadlineHtml && safeHeadlineHtml ? (
+
+ {headline}
+
+ ) : (
+
+ {headline}
+
+ )}
+
+
+
+ {/* Description/Subheader */}
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+ );
+}
+
+export { ElementHeader };
+export type { ElementHeaderProps };
diff --git a/packages/survey-ui/src/components/general/element-media.tsx b/packages/survey-ui/src/components/general/element-media.tsx
new file mode 100644
index 0000000000..79b37d4ca1
--- /dev/null
+++ b/packages/survey-ui/src/components/general/element-media.tsx
@@ -0,0 +1,102 @@
+import { Download, ExternalLink } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video";
+
+// Function to add extra params to videoUrls in order to reduce video controls
+const getVideoUrlWithParams = (videoUrl: string): string | undefined => {
+ // First convert to embed URL
+ const embedUrl = convertToEmbedUrl(videoUrl);
+ if (!embedUrl) return undefined;
+
+ const isYoutubeVideo = checkForYoutubeUrl(videoUrl);
+ const isVimeoUrl = checkForVimeoUrl(videoUrl);
+ const isLoomUrl = checkForLoomUrl(videoUrl);
+
+ if (isYoutubeVideo) {
+ // For YouTube, add parameters to embed URL
+ const separator = embedUrl.includes("?") ? "&" : "?";
+ return `${embedUrl}${separator}controls=0`;
+ } else if (isVimeoUrl) {
+ // For Vimeo, add parameters to embed URL
+ const separator = embedUrl.includes("?") ? "&" : "?";
+ return `${embedUrl}${separator}title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false`;
+ } else if (isLoomUrl) {
+ // For Loom, add parameters to embed URL
+ const separator = embedUrl.includes("?") ? "&" : "?";
+ return `${embedUrl}${separator}hide_share=true&hideEmbedTopBar=true&hide_title=true`;
+ }
+ return embedUrl;
+};
+
+interface ElementMediaProps {
+ imgUrl?: string;
+ videoUrl?: string;
+ altText?: string;
+}
+
+function ElementMedia({
+ imgUrl,
+ videoUrl,
+ altText = "Image",
+}: Readonly): React.JSX.Element {
+ const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ if (!imgUrl && !videoUrl) {
+ return <>>;
+ }
+
+ return (
+
+ {isLoading ? (
+
+ ) : null}
+ {imgUrl ? (
+
{
+ setIsLoading(false);
+ }}
+ onError={() => {
+ setIsLoading(false);
+ }}
+ />
+ ) : null}
+ {videoUrlWithParams ? (
+
+ ) : null}
+
+ {imgUrl ? : }
+
+
+ );
+}
+
+export { ElementMedia };
+export type { ElementMediaProps };
diff --git a/packages/survey-ui/src/components/general/input.stories.tsx b/packages/survey-ui/src/components/general/input.stories.tsx
new file mode 100644
index 0000000000..96a9e895ff
--- /dev/null
+++ b/packages/survey-ui/src/components/general/input.stories.tsx
@@ -0,0 +1,294 @@
+import type { Decorator, Meta, StoryContext, StoryObj } from "@storybook/react";
+import React from "react";
+import { Input, type InputProps } from "./input";
+
+// Styling options for the StylingPlayground story
+interface StylingOptions {
+ inputWidth: string;
+ inputHeight: string;
+ inputBgColor: string;
+ inputBorderColor: string;
+ inputBorderRadius: string;
+ inputFontFamily: string;
+ inputFontSize: string;
+ inputFontWeight: string;
+ inputColor: string;
+ inputPlaceholderColor: string;
+ inputPaddingX: string;
+ inputPaddingY: string;
+ inputShadow: string;
+ brandColor: string;
+}
+
+type StoryProps = InputProps & Partial;
+
+const meta: Meta = {
+ title: "UI-package/General/Input",
+ component: Input,
+ parameters: {
+ layout: "centered",
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ type: {
+ control: { type: "select" },
+ options: ["text", "email", "password", "number", "tel", "url", "search", "file"],
+ table: { category: "Component Props" },
+ },
+ placeholder: {
+ control: "text",
+ table: { category: "Component Props" },
+ },
+ disabled: {
+ control: "boolean",
+ table: { category: "Component Props" },
+ },
+ errorMessage: {
+ control: "text",
+ table: { category: "Component Props" },
+ },
+ dir: {
+ control: { type: "select" },
+ options: ["ltr", "rtl"],
+ table: { category: "Component Props" },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+const withCSSVariables: Decorator = (
+ Story: React.ComponentType,
+ context: StoryContext
+) => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
+ const args = context.args as StoryProps;
+ const {
+ inputWidth,
+ inputHeight,
+ inputBgColor,
+ inputBorderColor,
+ inputBorderRadius,
+ inputFontFamily,
+ inputFontSize,
+ inputFontWeight,
+ inputColor,
+ inputPlaceholderColor,
+ inputPaddingX,
+ inputPaddingY,
+ inputShadow,
+ brandColor,
+ } = args;
+
+ const cssVarStyle: React.CSSProperties & Record = {
+ "--fb-input-width": inputWidth,
+ "--fb-input-height": inputHeight,
+ "--fb-input-bg-color": inputBgColor,
+ "--fb-input-border-color": inputBorderColor,
+ "--fb-input-border-radius": inputBorderRadius,
+ "--fb-input-font-family": inputFontFamily,
+ "--fb-input-font-size": inputFontSize,
+ "--fb-input-font-weight": inputFontWeight,
+ "--fb-input-color": inputColor,
+ "--fb-input-placeholder-color": inputPlaceholderColor,
+ "--fb-input-padding-x": inputPaddingX,
+ "--fb-input-padding-y": inputPaddingY,
+ "--fb-input-shadow": inputShadow,
+ "--fb-survey-brand-color": brandColor,
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const StylingPlayground: Story = {
+ args: {
+ placeholder: "Enter text...",
+ },
+ argTypes: {
+ // Input Styling (CSS Variables) - Only for this story
+ inputWidth: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "100%" },
+ },
+ },
+ inputHeight: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "2.25rem" },
+ },
+ },
+ inputBgColor: {
+ control: "color",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "transparent" },
+ },
+ },
+ inputBorderColor: {
+ control: "color",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "var(--input)" },
+ },
+ },
+ inputBorderRadius: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "0.5rem" },
+ },
+ },
+ inputFontFamily: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "inherit" },
+ },
+ },
+ inputFontSize: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "0.875rem" },
+ },
+ },
+ inputFontWeight: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "400" },
+ },
+ },
+ inputColor: {
+ control: "color",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "var(--foreground)" },
+ },
+ },
+ inputPlaceholderColor: {
+ control: "color",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "var(--muted-foreground)" },
+ },
+ },
+ inputPaddingX: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "0.75rem" },
+ },
+ },
+ inputPaddingY: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "0.25rem" },
+ },
+ },
+ inputShadow: {
+ control: "text",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "0 1px 2px 0 rgb(0 0 0 / 0.05)" },
+ },
+ },
+ brandColor: {
+ control: "color",
+ table: {
+ category: "Input Styling",
+ defaultValue: { summary: "var(--fb-survey-brand-color)" },
+ },
+ },
+ },
+ decorators: [withCSSVariables],
+};
+
+export const Default: Story = {
+ args: {
+ placeholder: "Enter text...",
+ },
+};
+
+export const WithValue: Story = {
+ args: {
+ defaultValue: "Sample text",
+ placeholder: "Enter text...",
+ },
+};
+
+export const Email: Story = {
+ args: {
+ type: "email",
+ placeholder: "email@example.com",
+ },
+};
+
+export const Password: Story = {
+ args: {
+ type: "password",
+ placeholder: "Enter password",
+ },
+};
+
+export const NumberInput: Story = {
+ args: {
+ type: "number",
+ placeholder: "0",
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ placeholder: "Enter your email",
+ defaultValue: "invalid-email",
+ errorMessage: "Please enter a valid email address",
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ placeholder: "Disabled input",
+ disabled: true,
+ },
+};
+
+export const DisabledWithValue: Story = {
+ args: {
+ defaultValue: "Cannot edit this",
+ disabled: true,
+ },
+};
+
+export const RTL: Story = {
+ args: {
+ dir: "rtl",
+ placeholder: "أدخل النص هنا",
+ defaultValue: "نص تجريبي",
+ },
+};
+
+export const FullWidth: Story = {
+ args: {
+ placeholder: "Full width input",
+ className: "w-96",
+ },
+};
+
+export const WithErrorAndRTL: Story = {
+ args: {
+ dir: "rtl",
+ placeholder: "أدخل بريدك الإلكتروني",
+ errorMessage: "هذا الحقل مطلوب",
+ },
+};
diff --git a/packages/survey-ui/src/components/general/input.tsx b/packages/survey-ui/src/components/general/input.tsx
new file mode 100644
index 0000000000..795a92a9a4
--- /dev/null
+++ b/packages/survey-ui/src/components/general/input.tsx
@@ -0,0 +1,61 @@
+import * as React from "react";
+import { ElementError } from "@/components/general/element-error";
+import { cn } from "@/lib/utils";
+
+interface InputProps extends React.ComponentProps<"input"> {
+ /** Text direction for RTL language support */
+ dir?: "ltr" | "rtl" | "auto";
+ /** Error message to display above the input */
+ errorMessage?: string;
+}
+
+const Input = React.forwardRef(function Input(
+ { className, type, errorMessage, dir, ...props },
+ ref
+): React.JSX.Element {
+ const hasError = Boolean(errorMessage);
+
+ return (
+
+
+
+
+ );
+});
+
+export { Input };
+export type { InputProps };
diff --git a/packages/survey-ui/src/components/general/label.stories.tsx b/packages/survey-ui/src/components/general/label.stories.tsx
new file mode 100644
index 0000000000..eed7148b31
--- /dev/null
+++ b/packages/survey-ui/src/components/general/label.stories.tsx
@@ -0,0 +1,405 @@
+import type { Decorator, Meta, StoryContext, StoryObj } from "@storybook/react";
+import React from "react";
+import { Checkbox } from "./checkbox";
+import { Input } from "./input";
+import { Label, type LabelProps } from "./label";
+import { Textarea } from "./textarea";
+
+// Styling options for the StylingPlayground stories
+interface HeadlineStylingOptions {
+ headlineFontFamily: string;
+ headlineFontWeight: string;
+ headlineFontSize: string;
+ headlineColor: string;
+ headlineOpacity: string;
+}
+
+interface DescriptionStylingOptions {
+ descriptionFontFamily: string;
+ descriptionFontWeight: string;
+ descriptionFontSize: string;
+ descriptionColor: string;
+ descriptionOpacity: string;
+}
+
+interface DefaultStylingOptions {
+ labelFontFamily: string;
+ labelFontWeight: string;
+ labelFontSize: string;
+ labelColor: string;
+ labelOpacity: string;
+}
+
+type StoryProps = LabelProps &
+ Partial;
+
+const meta: Meta = {
+ title: "UI-package/General/Label",
+ component: Label,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A label component built with Radix UI primitives. Provides accessible labeling for form controls with proper association and styling.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ variant: {
+ control: "select",
+ options: ["default", "headline", "description"],
+ description: "Visual style variant of the label",
+ table: { category: "Component Props" },
+ },
+ htmlFor: {
+ control: { type: "text" },
+ description: "The id of the form control this label is associated with",
+ table: { category: "Component Props" },
+ },
+ style: {
+ control: "object",
+ table: { category: "Component Props" },
+ },
+ },
+ args: {
+ children: "Label text",
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables for headline variant
+const withHeadlineCSSVariables: Decorator = (
+ Story: React.ComponentType,
+ context: StoryContext
+) => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
+ const args = context.args as StoryProps;
+ const { headlineFontFamily, headlineFontWeight, headlineFontSize, headlineColor, headlineOpacity } = args;
+
+ const cssVarStyle: React.CSSProperties & Record = {
+ "--fb-element-headline-font-family": headlineFontFamily ?? undefined,
+ "--fb-element-headline-font-weight": headlineFontWeight ?? undefined,
+ "--fb-element-headline-font-size": headlineFontSize ?? undefined,
+ "--fb-element-headline-color": headlineColor ?? undefined,
+ "--fb-element-headline-opacity": headlineOpacity ?? undefined,
+ };
+
+ return (
+
+
+
+ );
+};
+
+// Decorator to apply CSS variables for description variant
+const withDescriptionCSSVariables: Decorator = (
+ Story: React.ComponentType,
+ context: StoryContext
+) => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
+ const args = context.args as StoryProps;
+ const {
+ descriptionFontFamily,
+ descriptionFontWeight,
+ descriptionFontSize,
+ descriptionColor,
+ descriptionOpacity,
+ } = args;
+
+ const cssVarStyle: React.CSSProperties & Record = {
+ "--fb-element-description-font-family": descriptionFontFamily ?? undefined,
+ "--fb-element-description-font-weight": descriptionFontWeight ?? undefined,
+ "--fb-element-description-font-size": descriptionFontSize ?? undefined,
+ "--fb-element-description-color": descriptionColor ?? undefined,
+ "--fb-element-description-opacity": descriptionOpacity ?? undefined,
+ };
+
+ return (
+
+
+
+ );
+};
+
+const withCustomCSSVariables: Decorator = (
+ Story: React.ComponentType,
+ context: StoryContext
+) => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
+ const args = context.args as StoryProps;
+ const { labelFontFamily, labelFontWeight, labelFontSize, labelColor, labelOpacity } = args;
+
+ const cssVarStyle: React.CSSProperties & Record = {
+ "--fb-label-font-family": labelFontFamily ?? undefined,
+ "--fb-label-font-weight": labelFontWeight ?? undefined,
+ "--fb-label-font-size": labelFontSize ?? undefined,
+ "--fb-label-color": labelColor ?? undefined,
+ "--fb-label-opacity": labelOpacity ?? undefined,
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const Default: Story = {
+ args: {},
+};
+
+export const WithInput: Story = {
+ render: () => (
+
+ Username
+
+
+ ),
+};
+
+export const WithCheckbox: Story = {
+ render: () => (
+
+
+ I agree to the terms and conditions
+
+ ),
+};
+
+export const WithTextarea: Story = {
+ render: () => (
+
+ Message
+
+
+ ),
+};
+
+export const Required: Story = {
+ render: () => (
+
+
+ Email address *
+
+
+
+ ),
+};
+
+export const Optional: Story = {
+ render: () => (
+
+
+ Website (optional)
+
+
+
+ ),
+};
+
+export const WithHelpText: Story = {
+ render: () => (
+
+
Password
+
+
+ Must be at least 8 characters with a mix of letters and numbers
+
+
+ ),
+};
+
+export const ErrorState: Story = {
+ render: () => (
+
+
+ Email address *
+
+
+
Please enter a valid email address
+
+ ),
+};
+
+export const FormSection: Story = {
+ render: () => (
+
+
+
Personal Information
+
+
+
+
+
Contact Information
+
+
+
+
+
Preferences
+
+
+
+ Subscribe to newsletter
+
+
+
+ Enable email notifications
+
+
+
+
+ ),
+};
+
+export const LongLabel: Story = {
+ render: () => (
+
+
+ This is a very long label that demonstrates how labels wrap when they contain a lot of text and need
+ to span multiple lines
+
+
+
+ ),
+};
+
+export const HeadlineVariant: Story = {
+ args: {
+ variant: "headline",
+ children: "Headline Label",
+ headlineFontFamily: "system-ui, sans-serif",
+ headlineFontWeight: "600",
+ headlineFontSize: "1.25rem",
+ headlineColor: "#1e293b",
+ headlineOpacity: "1",
+ },
+ argTypes: {
+ headlineFontFamily: {
+ control: "text",
+ table: { category: "Headline Styling" },
+ },
+ headlineFontWeight: {
+ control: "text",
+ table: { category: "Headline Styling" },
+ },
+ headlineFontSize: {
+ control: "text",
+ table: { category: "Headline Styling" },
+ },
+ headlineColor: {
+ control: "color",
+ table: { category: "Headline Styling" },
+ },
+ headlineOpacity: {
+ control: "text",
+ table: { category: "Headline Styling" },
+ },
+ },
+ decorators: [withHeadlineCSSVariables],
+};
+
+export const DescriptionVariant: Story = {
+ args: {
+ variant: "description",
+ children: "Description Label",
+ descriptionFontFamily: "system-ui, sans-serif",
+ descriptionFontWeight: "400",
+ descriptionFontSize: "0.875rem",
+ descriptionColor: "#64748b",
+ descriptionOpacity: "1",
+ },
+ argTypes: {
+ descriptionFontFamily: {
+ control: "text",
+ table: { category: "Description Styling" },
+ },
+ descriptionFontWeight: {
+ control: "text",
+ table: { category: "Description Styling" },
+ },
+ descriptionFontSize: {
+ control: "text",
+ table: { category: "Description Styling" },
+ },
+ descriptionColor: {
+ control: "color",
+ table: { category: "Description Styling" },
+ },
+ descriptionOpacity: {
+ control: "text",
+ table: { category: "Description Styling" },
+ },
+ },
+ decorators: [withDescriptionCSSVariables],
+};
+
+export const DefaultVariant: Story = {
+ args: {
+ variant: "default",
+ children: "Default Label",
+ labelFontFamily: "system-ui, sans-serif",
+ labelFontWeight: "500",
+ labelFontSize: "0.875rem",
+ labelColor: "#1e293b",
+ labelOpacity: "1",
+ },
+ argTypes: {
+ labelFontFamily: {
+ control: "text",
+ table: { category: "Default Label Styling" },
+ },
+ labelFontWeight: {
+ control: "text",
+ table: { category: "Default Label Styling" },
+ },
+ labelFontSize: {
+ control: "text",
+ table: { category: "Default Label Styling" },
+ },
+ labelColor: {
+ control: "color",
+ table: { category: "Default Label Styling" },
+ },
+ labelOpacity: {
+ control: "text",
+ table: { category: "Default Label Styling" },
+ },
+ },
+ decorators: [withCustomCSSVariables],
+};
diff --git a/packages/survey-ui/src/components/general/label.tsx b/packages/survey-ui/src/components/general/label.tsx
new file mode 100644
index 0000000000..346e5bd5e8
--- /dev/null
+++ b/packages/survey-ui/src/components/general/label.tsx
@@ -0,0 +1,123 @@
+import DOMPurify from "isomorphic-dompurify";
+import * as React from "react";
+import { cn, stripInlineStyles } from "@/lib/utils";
+
+interface LabelProps extends React.ComponentProps<"label"> {
+ /** Label variant for different styling contexts */
+ variant?: "default" | "headline" | "description";
+}
+
+/**
+ * Checks if a string contains valid HTML markup
+ * @param str - The input string to test
+ * @returns true if the string contains valid HTML elements, false otherwise
+ */
+const isValidHTML = (str: string): boolean => {
+ if (!str) return false;
+
+ try {
+ const doc = new DOMParser().parseFromString(str, "text/html");
+ const errorNode = doc.querySelector("parsererror");
+ if (errorNode) return false;
+ return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
+ } catch {
+ return false;
+ }
+};
+
+function Label({
+ className,
+ variant = "default",
+ children,
+ ...props
+}: Readonly): React.JSX.Element {
+ const { htmlFor, form, ...restProps } = props;
+
+ // Check if children is a string and contains HTML
+ const childrenString = typeof children === "string" ? children : null;
+ const strippedContent = childrenString ? stripInlineStyles(childrenString) : "";
+ const isHtml = childrenString ? isValidHTML(strippedContent) : false;
+ const safeHtml =
+ isHtml && strippedContent
+ ? DOMPurify.sanitize(strippedContent, {
+ ADD_ATTR: ["target"],
+ FORBID_ATTR: ["style"],
+ })
+ : "";
+
+ // Determine variant class
+ let variantClass = "label-default";
+ if (variant === "headline") {
+ variantClass = "label-headline";
+ } else if (variant === "description") {
+ variantClass = "label-description";
+ }
+
+ // Base classes - use flex-col for HTML content to allow line breaks, flex items-center for non-HTML
+ const baseClasses =
+ isHtml && safeHtml
+ ? "flex flex-col gap-2 leading-6 select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
+ : "flex items-center gap-2 leading-6 select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50";
+
+ // If HTML, render with dangerouslySetInnerHTML, otherwise render normally
+ if (isHtml && safeHtml) {
+ if (htmlFor) {
+ return (
+
+ );
+ }
+
+ return (
+ )}
+ dangerouslySetInnerHTML={{ __html: safeHtml }}
+ />
+ );
+ }
+
+ if (htmlFor) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+ )}>
+ {children}
+
+ );
+}
+
+export { Label };
+export type { LabelProps };
diff --git a/packages/survey-ui/src/components/general/popover.tsx b/packages/survey-ui/src/components/general/popover.tsx
new file mode 100644
index 0000000000..b09becf9da
--- /dev/null
+++ b/packages/survey-ui/src/components/general/popover.tsx
@@ -0,0 +1,39 @@
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function Popover({ ...props }: Readonly>) {
+ return ;
+}
+
+function PopoverTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: Readonly>) {
+ return (
+
+
+
+ );
+}
+
+function PopoverAnchor({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
diff --git a/packages/survey-ui/src/components/general/progress.stories.tsx b/packages/survey-ui/src/components/general/progress.stories.tsx
new file mode 100644
index 0000000000..65164b3397
--- /dev/null
+++ b/packages/survey-ui/src/components/general/progress.stories.tsx
@@ -0,0 +1,155 @@
+import type { Decorator, Meta, StoryContext, StoryObj } from "@storybook/react";
+import React from "react";
+import { Progress, type ProgressProps } from "./progress";
+
+// Styling options for the StylingPlayground story
+interface StylingOptions {
+ trackHeight: string;
+ trackBgColor: string;
+ trackBorderRadius: string;
+ indicatorBgColor: string;
+ indicatorBorderRadius: string;
+}
+
+type StoryProps = ProgressProps & Partial & Record;
+
+const meta: Meta = {
+ title: "UI-package/General/Progress",
+ component: Progress,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ value: {
+ control: { type: "range", min: 0, max: 100, step: 1 },
+ description: "Progress value (0-100)",
+ table: { category: "Component Props" },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Decorator to apply CSS variables from story args
+const withCSSVariables: Decorator = (
+ Story: React.ComponentType,
+ context: StoryContext
+) => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
+ const args = context.args as StoryProps;
+ const { trackHeight, trackBgColor, trackBorderRadius, indicatorBgColor, indicatorBorderRadius } = args;
+
+ const cssVarStyle: React.CSSProperties & Record = {
+ "--fb-progress-track-height": trackHeight,
+ "--fb-progress-track-bg-color": trackBgColor,
+ "--fb-progress-track-border-radius": trackBorderRadius,
+ "--fb-progress-indicator-bg-color": indicatorBgColor,
+ "--fb-progress-indicator-border-radius": indicatorBorderRadius,
+ };
+
+ return (
+
+
+
+ );
+};
+
+export const Default: Story = {
+ render: (args: StoryProps) => (
+
+ ),
+ args: {
+ value: 50,
+ },
+};
+
+export const Zero: Story = {
+ render: (args: StoryProps) => (
+
+ ),
+ args: {
+ value: 0,
+ },
+};
+
+export const Half: Story = {
+ render: (args: StoryProps) => (
+
+ ),
+ args: {
+ value: 50,
+ },
+};
+
+export const Complete: Story = {
+ render: (args: StoryProps) => (
+
+ ),
+ args: {
+ value: 100,
+ },
+};
+
+export const CustomStyles: Story = {
+ render: (args: StoryProps) => (
+
+ ),
+ args: {
+ value: 75,
+ trackHeight: "1.25rem",
+ trackBgColor: "hsl(0 0% 0% / 0.3)",
+ trackBorderRadius: "0.75rem",
+ indicatorBgColor: "hsl(142 76% 36%)",
+ indicatorBorderRadius: "0.75rem",
+ },
+ argTypes: {
+ trackHeight: {
+ control: "text",
+ table: {
+ category: "Progress Styling",
+ defaultValue: { summary: "0.5rem" },
+ },
+ },
+ trackBgColor: {
+ control: "color",
+ table: {
+ category: "Progress Styling",
+ defaultValue: { summary: "hsl(222.2 47.4% 11.2% / 0.2)" },
+ },
+ },
+ trackBorderRadius: {
+ control: "text",
+ table: {
+ category: "Progress Styling",
+ defaultValue: { summary: "var(--radius)" },
+ },
+ },
+ indicatorBgColor: {
+ control: "color",
+ table: {
+ category: "Progress Styling",
+ defaultValue: { summary: "hsl(222.2 47.4% 11.2%)" },
+ },
+ },
+ indicatorBorderRadius: {
+ control: "text",
+ table: {
+ category: "Progress Styling",
+ defaultValue: { summary: "var(--radius)" },
+ },
+ },
+ },
+ decorators: [withCSSVariables],
+};
diff --git a/packages/survey-ui/src/components/general/progress.tsx b/packages/survey-ui/src/components/general/progress.tsx
new file mode 100644
index 0000000000..c8165e8290
--- /dev/null
+++ b/packages/survey-ui/src/components/general/progress.tsx
@@ -0,0 +1,29 @@
+import * as ProgressPrimitive from "@radix-ui/react-progress";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface ProgressProps extends Omit, "children"> {
+ value?: number;
+}
+
+function Progress({ className, value, ...props }: Readonly): React.JSX.Element {
+ const progressValue: number = typeof value === "number" ? value : 0;
+ return (
+ // @ts-expect-error - React types version mismatch - the project uses React 19 types, but some Radix UI packages (@radix-ui/react-progress) bundle their own older React types, creating incompatible Ref type definitions
+
+
+
+ );
+}
+
+export { Progress };
diff --git a/packages/survey-ui/src/components/general/radio-group.stories.tsx b/packages/survey-ui/src/components/general/radio-group.stories.tsx
new file mode 100644
index 0000000000..8cedd4f605
--- /dev/null
+++ b/packages/survey-ui/src/components/general/radio-group.stories.tsx
@@ -0,0 +1,312 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { Label } from "./label";
+import { RadioGroup, RadioGroupItem } from "./radio-group";
+
+const meta: Meta = {
+ title: "UI-package/General/RadioGroup",
+ component: RadioGroup,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A radio group component built with Radix UI primitives. Allows users to select one option from a set of mutually exclusive choices.",
+ },
+ },
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ defaultValue: {
+ control: { type: "text" },
+ description: "The default selected value",
+ },
+ disabled: {
+ control: { type: "boolean" },
+ description: "Whether the entire radio group is disabled",
+ },
+ required: {
+ control: { type: "boolean" },
+ description: "Whether a selection is required",
+ },
+ dir: {
+ control: { type: "select" },
+ options: ["ltr", "rtl"],
+ description: "Text direction",
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args: React.ComponentProps) => (
+
+
+
+ Option 1
+
+
+
+ Option 2
+
+
+
+ Option 3
+
+
+ ),
+ args: {
+ defaultValue: "option1",
+ },
+};
+
+export const WithoutDefault: Story = {
+ render: () => (
+
+
+
+ Option 1
+
+
+
+ Option 2
+
+
+
+ Option 3
+
+
+ ),
+};
+
+export const Disabled: Story = {
+ render: () => (
+
+
+
+ Option 1 (Selected)
+
+
+
+ Option 2
+
+
+
+ Option 3
+
+
+ ),
+};
+
+export const SingleDisabledOption: Story = {
+ render: () => (
+
+
+
+ Option 1
+
+
+
+ Option 2 (Disabled)
+
+
+
+ Option 3
+
+
+ ),
+};
+
+export const PaymentMethod: Story = {
+ render: () => (
+
+
Payment Method
+
+
+
+ Credit Card
+
+
+
+ PayPal
+
+
+
+ Bank Transfer
+
+
+
+ Cryptocurrency (Coming Soon)
+
+
+
+ ),
+};
+
+export const SurveyElement: Story = {
+ render: () => (
+
+
+
How satisfied are you with our service?
+
+ Please select one option that best describes your experience.
+
+
+
+
+
+ Very satisfied
+
+
+
+ Satisfied
+
+
+
+ Neutral
+
+
+
+ Dissatisfied
+
+
+
+ Very dissatisfied
+
+
+
+ ),
+};
+
+export const WithDescriptions: Story = {
+ render: () => (
+
+
Choose your plan
+
+
+
+
+
+ Basic Plan
+
+
+
+ Perfect for individuals. Includes basic features and 5GB storage.
+
+
$9/month
+
+
+
+
+
+ Pro Plan
+
+
+
+ Great for small teams. Advanced features and 50GB storage.
+
+
$29/month
+
+
+
+
+
+ Enterprise Plan
+
+
+
+ For large organizations. Custom features and unlimited storage.
+
+
Contact sales
+
+
+
+ ),
+};
+
+export const Required: Story = {
+ render: () => (
+
+
+
+ Gender *
+
+
This field is required
+
+
+
+
+ Male
+
+
+
+ Female
+
+
+
+ Other
+
+
+
+ Prefer not to say
+
+
+
+ ),
+};
+
+export const WithRTL: Story = {
+ render: () => (
+
+
+
+
+ Male
+
+
+
+ Female
+
+
+
+ ),
+};
+
+export const WithErrorMessage: Story = {
+ render: () => (
+
+
+
+ Option 1
+
+
+
+ Option 2
+
+
+
+ Option 3
+
+
+ ),
+};
+
+export const WithErrorMessageAndRTL: Story = {
+ render: () => (
+
+
+
+ اختر الخيار 1
+
+
+
+ اختر الخيار 2
+
+
+
+ اختر الخيار 3
+
+
+ ),
+};
diff --git a/packages/survey-ui/src/components/general/radio-group.tsx b/packages/survey-ui/src/components/general/radio-group.tsx
new file mode 100644
index 0000000000..312163b195
--- /dev/null
+++ b/packages/survey-ui/src/components/general/radio-group.tsx
@@ -0,0 +1,58 @@
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import { AlertCircle, CircleIcon } from "lucide-react";
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function RadioGroup({
+ className,
+ errorMessage,
+ dir,
+ ...props
+}: React.ComponentProps & {
+ errorMessage?: string;
+ dir?: "ltr" | "rtl";
+}): React.JSX.Element {
+ return (
+
+ {errorMessage ?
: null}
+
+ {errorMessage ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+function RadioGroupItem({
+ className,
+ ...props
+}: React.ComponentProps): React.JSX.Element {
+ return (
+
+
+
+
+
+ );
+}
+
+export { RadioGroup, RadioGroupItem };
diff --git a/packages/survey-ui/src/components/general/smileys.tsx b/packages/survey-ui/src/components/general/smileys.tsx
new file mode 100644
index 0000000000..520abbb82d
--- /dev/null
+++ b/packages/survey-ui/src/components/general/smileys.tsx
@@ -0,0 +1,466 @@
+import type { FunctionComponent } from "react";
+
+export const TiredFace: FunctionComponent> = (props) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const WearyFace: FunctionComponent> = (props) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const PerseveringFace: FunctionComponent> = (props) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const FrowningFace: FunctionComponent> = (props) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const ConfusedFace: FunctionComponent> = (props) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const NeutralFace: FunctionComponent> = (props) => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const SlightlySmilingFace: FunctionComponent> = (props) => {
+ return (
+
+
+