Compare commits

...

14 Commits

Author SHA1 Message Date
Anshuman Pandey
68f1f42f81 chore: backports sanitize html fixes (#7016)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2025-12-19 15:08:03 +05:30
Dhruwang Jariwala
086d8177dc fix: (BACKPORT) missing question media (#6997) (#7015)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-19 14:39:25 +05:30
Dhruwang Jariwala
efbe27fa95 fix: (BACKPORT) missing required question warning (#6998) (#7005) 2025-12-19 14:00:34 +05:30
Dhruwang Jariwala
ca1a0053b8 fix: (BACKPORT) border radius for inputs (#6996) (#7007) 2025-12-19 14:00:16 +05:30
Johannes
035093e702 fix: (BACKPORT) replaced bg-white with survey-bg color in surveys package (#7004) (#7013)
Co-authored-by: Luis Gustavo S. Barreto <gustavo@ossystems.com.br>
2025-12-19 13:55:23 +05:30
Anshuman Pandey
75d33a1716 fix: (BACKPORT) empty button in cta question (#6995) (#7008) 2025-12-19 13:08:52 +05:30
Dhruwang Jariwala
97ab194107 fix: empty button in cta question (#6995) 2025-12-19 11:14:17 +05:30
Anshuman Pandey
e9cc636510 chore: backports the isExternalUrlAllowed addition to welcome card (#6994)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-18 01:25:22 -08:00
Matti Nannt
e71f3f412c feat: Add base path support for Formbricks (#6853) 2025-12-17 17:13:32 +00:00
Anshuman Pandey
07ed926225 fix: updates the patch to fix the next-auth no proxy issue (#6987)
Co-authored-by: Matti Nannt <matti@formbricks.com>
2025-12-17 17:11:40 +00:00
Dhruwang Jariwala
15dc83a4eb feat: improved survey UI (#6988)
Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-17 16:13:28 +01:00
Johannes
3ce07edf43 chore: replacing intercom with chatwoot (#6980)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-16 16:16:09 +00:00
Johannes
0f34d9cc5f feat: standardize URL prefilling with option ID support and MQB support (#6970)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-16 10:09:47 +00:00
Matti Nannt
e9f800f017 fix: prepare pnpm in runner stage for airgapped deployments (#6925) 2025-12-15 13:30:55 +00:00
201 changed files with 18313 additions and 5189 deletions

View File

@@ -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 (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
{/* TODO: Add your question-specific UI here */}
{/* Error message */}
{errorMessage && (
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
<span>{errorMessage}</span>
</div>
)}
</div>
);
}
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<StylingOptions>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
// Decorator to apply CSS variables from story args
const withCSSVariables: Decorator<StoryProps> = (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<string, string | undefined> = {
"--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 (
<div style={cssVarStyle} className="w-[600px]">
<Story />
</div>
);
};
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

View File

@@ -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
@@ -189,8 +193,9 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
# Chatwoot
# CHATWOOT_BASE_URL=
# CHATWOOT_WEBSITE_TOKEN=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -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,
},
},
};

View File

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

View File

@@ -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
@@ -73,8 +77,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -134,12 +138,13 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]

View File

@@ -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 = ({
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }}

View File

@@ -5,6 +5,7 @@ import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@fo
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -47,6 +48,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
throw new Error(t("common.organization_teams_not_found"));
}
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -62,6 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
<Button

View File

@@ -1,6 +1,7 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
@@ -15,6 +16,7 @@ interface EnvironmentLayoutProps {
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
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}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar

View File

@@ -46,6 +46,7 @@ interface NavigationProps {
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
}
export const MainNavigation = ({
@@ -56,6 +57,7 @@ export const MainNavigation = ({
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -286,15 +288,16 @@ export const MainNavigation = ({
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
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={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -1,5 +1,6 @@
import { getServerSession } from "next-auth";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -18,7 +19,15 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper user={user} />
{IS_CHATWOOT_CONFIGURED && (
<ChatwootWidget
userEmail={user?.email}
userName={user?.name}
userId={user?.id}
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
chatwootBaseUrl={CHATWOOT_BASE_URL}
/>
)}
<ToasterClient />
{children}
</>

View File

@@ -1,11 +1,9 @@
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper />
{children}
</>
);

View File

@@ -0,0 +1,97 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
interface ChatwootWidgetProps {
chatwootBaseUrl: string;
chatwootWebsiteToken?: string;
userEmail?: string | null;
userName?: string | null;
userId?: string | null;
}
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
export const ChatwootWidget = ({
userEmail,
userName,
userId,
chatwootWebsiteToken,
chatwootBaseUrl,
}: ChatwootWidgetProps) => {
const userSetRef = useRef(false);
const setUserInfo = useCallback(() => {
const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, {
email: userEmail,
name: userName,
});
userSetRef.current = true;
}
}, [userId, userEmail, userName]);
useEffect(() => {
if (!chatwootWebsiteToken) return;
const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID);
if (existingScript) return;
const script = document.createElement("script");
script.src = `${chatwootBaseUrl}/packs/js/sdk.js`;
script.id = CHATWOOT_SCRIPT_ID;
script.async = true;
script.onload = () => {
(
globalThis as unknown as {
chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void };
}
).chatwootSDK?.run({
websiteToken: chatwootWebsiteToken,
baseUrl: chatwootBaseUrl,
});
};
document.head.appendChild(script);
const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
// Check if Chatwoot is already ready
if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo();
}
return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) {
$chatwoot.reset();
}
const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID);
scriptElement?.remove();
userSetRef.current = false;
};
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
return null;
};

View File

@@ -1,67 +0,0 @@
"use client";
import Intercom from "@intercom/messenger-js-sdk";
import { useCallback, useEffect } from "react";
import { TUser } from "@formbricks/types/user";
interface IntercomClientProps {
isIntercomConfigured: boolean;
intercomUserHash?: string;
user?: TUser | null;
intercomAppId?: string;
}
export const IntercomClient = ({
user,
intercomUserHash,
isIntercomConfigured,
intercomAppId,
}: IntercomClientProps) => {
const initializeIntercom = useCallback(() => {
let initParams = {};
if (user && intercomUserHash) {
const { id, name, email, createdAt } = user;
initParams = {
user_id: id,
user_hash: intercomUserHash,
name,
email,
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
};
}
Intercom({
app_id: intercomAppId!,
...initParams,
});
}, [user, intercomUserHash, intercomAppId]);
useEffect(() => {
try {
if (isIntercomConfigured) {
if (!intercomAppId) {
throw new Error("Intercom app ID is required");
}
if (user && !intercomUserHash) {
throw new Error("Intercom user hash is required");
}
initializeIntercom();
}
return () => {
// Shutdown Intercom when component unmounts
if (typeof window !== "undefined" && window.Intercom) {
window.Intercom("shutdown");
}
};
} catch (error) {
console.error("Failed to initialize Intercom:", error);
}
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
return null;
};

View File

@@ -1,26 +0,0 @@
import { createHmac } from "crypto";
import type { TUser } from "@formbricks/types/user";
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants";
import { IntercomClient } from "./IntercomClient";
interface IntercomClientWrapperProps {
user?: TUser | null;
}
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
let intercomUserHash: string | undefined;
if (user) {
const secretKey = INTERCOM_SECRET_KEY;
if (secretKey) {
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
}
}
return (
<IntercomClient
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
user={user}
intercomAppId={INTERCOM_APP_ID}
intercomUserHash={intercomUserHash}
/>
);
};

View File

@@ -215,9 +215,9 @@ export const BILLING_LIMITS = {
},
} as const;
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN;
export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com";
export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN);
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;

View File

@@ -39,11 +39,12 @@ export const env = createEnv({
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
INTERCOM_SECRET_KEY: z.string().optional(),
INTERCOM_APP_ID: z.string().optional(),
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
CHATWOOT_BASE_URL: z.string().url().optional(),
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(),
@@ -162,15 +163,16 @@ export const env = createEnv({
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
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,
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,

View File

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

View File

@@ -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 ? (
<FormWrapper>
<p className="text-center">{t("auth.verify.verifying")}</p>
<SignIn token={token} />
<SignIn token={token} webAppUrl={WEBAPP_URL} />
</FormWrapper>
) : (
<p className="text-center">{t("auth.verify.no_token_provided")}</p>

View File

@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
return (
<Container>
{overrideFileUploadResponse ? (
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
<Text className="mt-0 text-sm break-words whitespace-pre-wrap italic">
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
</Text>
) : (
@@ -65,6 +65,6 @@ export const renderEmailResponseValue = async (
);
default:
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response}</Text>;
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response}</Text>;
}
};

View File

@@ -74,7 +74,7 @@ export async function ResponseFinishedEmail({
)}
{variable.name}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
<Text className="mt-0 font-medium break-words whitespace-pre-wrap">
{variableResponse}
</Text>
</Column>
@@ -94,7 +94,7 @@ export async function ResponseFinishedEmail({
<Text className="mb-2 flex items-center gap-2 text-sm">
{hiddenFieldId} <EyeOffIcon />
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
<Text className="mt-0 text-sm break-words whitespace-pre-wrap">
{hiddenFieldResponse}
</Text>
</Column>

View File

@@ -3,8 +3,8 @@
import Link from "next/link";
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { WEBAPP_URL } from "@/lib/constants";
import { getActionClasses } from "@/lib/actionClass/service";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server";

View File

@@ -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}
/>
</div>
</div>

View File

@@ -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 (
<PageContentWrapper>
@@ -49,6 +51,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
publicDomain={publicDomain}
/>
</SettingsCard>
<SettingsCard

View File

@@ -284,7 +284,7 @@ export const BlockCard = ({
</div>
<button
className="opacity-0 hover:cursor-move group-hover:opacity-100"
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
aria-label="Drag to reorder block">
<GripIcon className="h-4 w-4" />
</button>

View File

@@ -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 = ({
<div
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<Hand className="h-4 w-4" />
@@ -135,6 +137,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
<div className="mt-3">
@@ -150,6 +153,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
@@ -170,6 +174,7 @@ export const EditWelcomeCard = ({
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>

View File

@@ -808,6 +808,7 @@ export const ElementsView = ({
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
)}

View File

@@ -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<TSurveyEditorTabs>("elements");
const [activeElementId, setActiveElementId] = useState<string | null>(null);
@@ -272,6 +274,7 @@ export const SurveyEditor = ({
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
isSpamProtectionAllowed={isSpamProtectionAllowed}
publicDomain={publicDomain}
/>
</aside>
</div>

View File

@@ -400,7 +400,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
{!isStorageConfigured && (
<div>
<Alert variant="warning" size="small">

View File

@@ -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 (
<SurveyEditor
@@ -134,6 +136,7 @@ export const SurveyEditorPage = async (props) => {
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
publicDomain={publicDomain}
/>
);
};

View File

@@ -1,6 +1,6 @@
import { Column, Hr, Row, Text } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import sanitizeHtml from "sanitize-html";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -35,11 +35,16 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
<>
<div
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
__html: sanitizeHtml(body, {
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
allowedAttributes: {
a: ["href", "rel", "target"],
"*": ["dir", "class"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: {
a: ["http", "https"],
},
}),
}}
/>
@@ -84,7 +89,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
? `${t("emails.number_variable")}: ${variable.name}`
: `${t("emails.text_variable")}: ${variable.name}`}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{variableResponse}
</Text>
</Column>
@@ -107,7 +112,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
<Text className="mb-2 text-sm font-semibold text-slate-900">
{t("emails.hidden_field")}: {hiddenFieldId}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{hiddenFieldResponse}
</Text>
</Column>

View File

@@ -155,7 +155,7 @@ export const FollowUpItem = ({
</div>
</button>
<div className="absolute right-4 top-4 flex items-center">
<div className="absolute top-4 right-4 flex items-center">
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"

View File

@@ -8,7 +8,7 @@ import { TResponseData } from "@formbricks/types/responses";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
import { SurveyInline } from "@/modules/ui/components/survey";
interface SurveyClientWrapperProps {

View File

@@ -3,9 +3,9 @@ import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getPrefillValue } from "./utils";
import { getPrefillValue } from "./index";
describe("survey link utils", () => {
describe("prefill integration tests", () => {
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -76,15 +76,7 @@ describe("survey link utils", () => {
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
},
{
id: "q7",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA Question" },
required: false,
buttonLabel: { default: "Click me" },
buttonExternal: false,
buttonUrl: "",
},
{
id: "q8",
type: TSurveyElementTypeEnum.Consent,
@@ -162,13 +154,21 @@ describe("survey link utils", () => {
expect(result).toEqual({ q1: "Open text answer" });
});
test("validates MultipleChoiceSingle questions", () => {
test("validates MultipleChoiceSingle questions with label", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "Option 1");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q2: "Option 1" });
});
test("validates MultipleChoiceSingle questions with option ID", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "c2");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Option ID is converted to label
expect(result).toEqual({ q2: "Option 2" });
});
test("invalidates MultipleChoiceSingle with non-existent option", () => {
const searchParams = new URLSearchParams();
searchParams.set("q2", "Non-existent option");
@@ -183,13 +183,29 @@ describe("survey link utils", () => {
expect(result).toEqual({ q3: "Custom answer" });
});
test("handles MultipleChoiceMulti questions", () => {
test("handles MultipleChoiceMulti questions with labels", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4,Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti questions with option IDs", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "c4,c5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Option IDs are converted to labels
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti with mixed IDs and labels", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "c4,Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
// Mixed: ID converted to label + label stays as-is
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("handles MultipleChoiceMulti with Other", () => {
const searchParams = new URLSearchParams();
searchParams.set("q5", "Option 6,Custom answer");
@@ -211,20 +227,6 @@ describe("survey link utils", () => {
expect(result).toBeUndefined();
});
test("handles CTA questions with clicked value", () => {
const searchParams = new URLSearchParams();
searchParams.set("q7", "clicked");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q7: "clicked" });
});
test("handles CTA questions with dismissed value", () => {
const searchParams = new URLSearchParams();
searchParams.set("q7", "dismissed");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q7: "" });
});
test("validates Consent questions", () => {
const searchParams = new URLSearchParams();
searchParams.set("q8", "accepted");
@@ -293,4 +295,18 @@ describe("survey link utils", () => {
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toBeUndefined();
});
test("handles whitespace in comma-separated values", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4 , Option 5");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
test("ignores trailing commas in multi-select", () => {
const searchParams = new URLSearchParams();
searchParams.set("q4", "Option 4,Option 5,");
const result = getPrefillValue(mockSurvey, searchParams, "default");
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
});
});

View File

@@ -0,0 +1,73 @@
import { TResponseData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { transformElement } from "./transformers";
import { validateElement } from "./validators";
/**
* Extract prefilled values from URL search parameters
*
* Supports prefilling for all survey element types with the following features:
* - Option ID or label matching for choice-based elements (single/multi-select, ranking, picture selection)
* - Comma-separated values for multi-select and ranking
* - Backward compatibility with label-based prefilling
*
* @param survey - The survey object containing blocks and elements
* @param searchParams - URL search parameters (e.g., from useSearchParams() or new URLSearchParams())
* @param languageId - Current language code for label matching
* @returns Object with element IDs as keys and prefilled values, or undefined if no valid prefills
*
* @example
* // Single select with option ID
* ?questionId=option-abc123
*
* // Multi-select with labels (backward compatible)
* ?questionId=Option1,Option2,Option3
*
* // Ranking with option IDs
* ?rankingId=choice-3,choice-1,choice-2
*
* // NPS question
* ?npsId=9
*
* // Multiple questions
* ?q1=answer1&q2=10&q3=option-xyz
*/
export const getPrefillValue = (
survey: TSurvey,
searchParams: URLSearchParams,
languageId: string
): TResponseData | undefined => {
const prefillData: TResponseData = {};
const elements = getElementsFromBlocks(survey.blocks);
searchParams.forEach((value, key) => {
try {
// Skip reserved parameter names
if (FORBIDDEN_IDS.includes(key)) {
return;
}
// Find matching element
const element = elements.find((el) => el.id === key);
if (!element) {
return;
}
// Validate the value for this element type (returns match data)
const validationResult = validateElement(element, value, languageId);
if (!validationResult.isValid) {
return;
}
// Transform the value using pre-matched data from validation
const transformedValue = transformElement(validationResult, value, languageId);
prefillData[element.id] = transformedValue;
} catch (error) {
// Catch any errors to prevent one bad prefill from breaking all prefills
console.error(`[Prefill] Error processing prefill for ${key}:`, error);
}
});
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
};

View File

@@ -0,0 +1,94 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { matchMultipleOptionsByIdOrLabel, matchOptionByIdOrLabel } from "./matchers";
describe("matchOptionByIdOrLabel", () => {
const choices = [
{ id: "choice-1", label: { en: "First", de: "Erste" } },
{ id: "choice-2", label: { en: "Second", de: "Zweite" } },
{ id: "other", label: { en: "Other", de: "Andere" } },
];
test("matches by ID", () => {
const result = matchOptionByIdOrLabel(choices, "choice-1", "en");
expect(result).toEqual(choices[0]);
});
test("matches by label in English", () => {
const result = matchOptionByIdOrLabel(choices, "First", "en");
expect(result).toEqual(choices[0]);
});
test("matches by label in German", () => {
const result = matchOptionByIdOrLabel(choices, "Zweite", "de");
expect(result).toEqual(choices[1]);
});
test("prefers ID match over label match", () => {
const choicesWithConflict = [
{ id: "First", label: { en: "Not First" } },
{ id: "choice-2", label: { en: "First" } },
];
const result = matchOptionByIdOrLabel(choicesWithConflict, "First", "en");
expect(result).toEqual(choicesWithConflict[0]); // Matches by ID, not label
});
test("returns null for no match", () => {
const result = matchOptionByIdOrLabel(choices, "NonExistent", "en");
expect(result).toBeNull();
});
test("returns null for empty string", () => {
const result = matchOptionByIdOrLabel(choices, "", "en");
expect(result).toBeNull();
});
test("handles special characters in labels", () => {
const specialChoices = [{ id: "c1", label: { en: "Option (1)" } }];
const result = matchOptionByIdOrLabel(specialChoices, "Option (1)", "en");
expect(result).toEqual(specialChoices[0]);
});
});
describe("matchMultipleOptionsByIdOrLabel", () => {
const choices = [
{ id: "choice-1", label: { en: "First" } },
{ id: "choice-2", label: { en: "Second" } },
{ id: "choice-3", label: { en: "Third" } },
];
test("matches multiple values by ID", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "choice-3"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("matches multiple values by label", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "Third"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("matches mixed IDs and labels", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "Second", "choice-3"], "en");
expect(result).toEqual([choices[0], choices[1], choices[2]]);
});
test("preserves order of values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["Third", "First", "Second"], "en");
expect(result).toEqual([choices[2], choices[0], choices[1]]);
});
test("skips non-matching values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "NonExistent", "Third"], "en");
expect(result).toEqual([choices[0], choices[2]]);
});
test("returns empty array for all non-matching values", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, ["NonExistent1", "NonExistent2"], "en");
expect(result).toEqual([]);
});
test("handles empty values array", () => {
const result = matchMultipleOptionsByIdOrLabel(choices, [], "en");
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,42 @@
import { TSurveyElementChoice } from "@formbricks/types/surveys/elements";
/**
* Match a value against element choices by ID first, then by label
* This enables both option ID-based and label-based prefilling
*
* @param choices - Array of choice objects with id and label
* @param value - Value from URL parameter (either choice ID or label text)
* @param languageCode - Current language code for label matching
* @returns Matched choice or null if no match found
*/
export const matchOptionByIdOrLabel = (
choices: TSurveyElementChoice[],
value: string,
languageCode: string
): TSurveyElementChoice | null => {
const matchById = choices.find((choice) => choice.id === value);
if (matchById) return matchById;
const matchByLabel = choices.find((choice) => choice.label[languageCode] === value);
if (matchByLabel) return matchByLabel;
return null;
};
/**
* Match multiple values against choices
* Used for multi-select and ranking elements
*
* @param choices - Array of choice objects
* @param values - Array of values from URL parameter
* @param languageCode - Current language code
* @returns Array of matched choices (preserves order)
*/
export const matchMultipleOptionsByIdOrLabel = (
choices: TSurveyElementChoice[],
values: string[],
languageCode: string
): TSurveyElementChoice[] =>
values
.map((value) => matchOptionByIdOrLabel(choices, value, languageCode))
.filter((match) => match !== null);

View File

@@ -0,0 +1,64 @@
import "@testing-library/jest-dom/vitest";
import { describe, expect, test } from "vitest";
import { parseCommaSeparated, parseNumber } from "./parsers";
describe("parseCommaSeparated", () => {
test("parses simple comma-separated values", () => {
expect(parseCommaSeparated("a,b,c")).toEqual(["a", "b", "c"]);
});
test("trims whitespace from values", () => {
expect(parseCommaSeparated("a , b , c")).toEqual(["a", "b", "c"]);
expect(parseCommaSeparated(" a, b, c ")).toEqual(["a", "b", "c"]);
});
test("filters out empty values", () => {
expect(parseCommaSeparated("a,,b")).toEqual(["a", "b"]);
expect(parseCommaSeparated("a,b,")).toEqual(["a", "b"]);
expect(parseCommaSeparated(",a,b")).toEqual(["a", "b"]);
});
test("handles empty string", () => {
expect(parseCommaSeparated("")).toEqual([]);
});
test("handles single value", () => {
expect(parseCommaSeparated("single")).toEqual(["single"]);
});
test("handles values with spaces", () => {
expect(parseCommaSeparated("First Choice,Second Choice")).toEqual(["First Choice", "Second Choice"]);
});
});
describe("parseNumber", () => {
test("parses valid integers", () => {
expect(parseNumber("5")).toBe(5);
expect(parseNumber("0")).toBe(0);
expect(parseNumber("10")).toBe(10);
});
test("parses valid floats", () => {
expect(parseNumber("5.5")).toBe(5.5);
expect(parseNumber("0.1")).toBe(0.1);
});
test("parses negative numbers", () => {
expect(parseNumber("-5")).toBe(-5);
expect(parseNumber("-5.5")).toBe(-5.5);
});
test("handles ampersand replacement", () => {
expect(parseNumber("5&5")).toBe(null); // Invalid after replacement
});
test("returns null for invalid strings", () => {
expect(parseNumber("abc")).toBeNull();
expect(parseNumber("")).toBeNull();
expect(parseNumber("5a")).toBeNull();
});
test("returns null for NaN result", () => {
expect(parseNumber("NaN")).toBeNull();
});
});

View File

@@ -0,0 +1,31 @@
/**
* Simple parsing helpers for URL parameter values
*/
/**
* Parse comma-separated values from URL parameter
* Used for multi-select and ranking elements
* Handles whitespace trimming and empty values
*/
export const parseCommaSeparated = (value: string): string[] => {
return value
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
};
/**
* Parse number from URL parameter
* Used for NPS and Rating elements
* Returns null if parsing fails
*/
export const parseNumber = (value: string): number | null => {
try {
// Handle `&` being used instead of `;` in some cases
const cleanedValue = value.replaceAll("&", ";");
const num = Number(JSON.parse(cleanedValue));
return Number.isNaN(num) ? null : num;
} catch {
return null;
}
};

View File

@@ -0,0 +1,100 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { parseNumber } from "./parsers";
import {
TValidationResult,
isMultiChoiceResult,
isPictureSelectionResult,
isSingleChoiceResult,
} from "./types";
export const transformOpenText = (answer: string): string => {
return answer;
};
export const transformMultipleChoiceSingle = (
validationResult: TValidationResult,
answer: string,
language: string
): string => {
if (!isSingleChoiceResult(validationResult)) return answer;
const { matchedChoice } = validationResult;
// If we have a matched choice, return its label
if (matchedChoice) {
return matchedChoice.label[language] || answer;
}
// If no matched choice (null), it's an "other" value - return original
return answer;
};
export const transformMultipleChoiceMulti = (validationResult: TValidationResult): string[] => {
if (!isMultiChoiceResult(validationResult)) return [];
const { matched, others } = validationResult;
// Return matched choices + joined "other" values as single string
if (others.length > 0) {
return [...matched, others.join(",")];
}
return matched;
};
export const transformNPS = (answer: string): number => {
const num = parseNumber(answer);
return num ?? 0;
};
export const transformRating = (answer: string): number => {
const num = parseNumber(answer);
return num ?? 0;
};
export const transformConsent = (answer: string): string => {
if (answer === "dismissed") return "";
return answer;
};
export const transformPictureSelection = (validationResult: TValidationResult): string[] => {
if (!isPictureSelectionResult(validationResult)) return [];
return validationResult.selectedIds;
};
/**
* Main transformation dispatcher
* Routes to appropriate transformer based on element type
* Uses pre-matched data from validation result to avoid duplicate matching
*/
export const transformElement = (
validationResult: TValidationResult,
answer: string,
language: string
): string | number | string[] => {
if (!validationResult.isValid) return "";
try {
switch (validationResult.type) {
case TSurveyElementTypeEnum.OpenText:
return transformOpenText(answer);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return transformMultipleChoiceSingle(validationResult, answer, language);
case TSurveyElementTypeEnum.Consent:
return transformConsent(answer);
case TSurveyElementTypeEnum.Rating:
return transformRating(answer);
case TSurveyElementTypeEnum.NPS:
return transformNPS(answer);
case TSurveyElementTypeEnum.PictureSelection:
return transformPictureSelection(validationResult);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return transformMultipleChoiceMulti(validationResult);
default:
return "";
}
} catch {
return "";
}
};

View File

@@ -0,0 +1,62 @@
import { TSurveyElementChoice, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
type TInvalidResult = {
isValid: false;
};
// Base valid result for simple types (no match data needed)
type TSimpleValidResult = {
isValid: true;
};
// Single choice match result (MultipleChoiceSingle)
type TSingleChoiceValidResult = {
isValid: true;
matchedChoice: TSurveyElementChoice | null; // null means "other" value
};
// Multi choice match result (MultipleChoiceMulti)
type TMultiChoiceValidResult = {
isValid: true;
matched: string[]; // matched labels
others: string[]; // other text values
};
// Picture selection result (indices are already validated)
type TPictureSelectionValidResult = {
isValid: true;
selectedIds: string[];
};
// Discriminated union for all validation results
export type TValidationResult =
| (TInvalidResult & { type?: TSurveyElementTypeEnum })
| (TSimpleValidResult & {
type:
| TSurveyElementTypeEnum.OpenText
| TSurveyElementTypeEnum.NPS
| TSurveyElementTypeEnum.Rating
| TSurveyElementTypeEnum.Consent;
})
| (TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle })
| (TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti })
| (TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection });
// Type guards for narrowing validation results
export const isValidResult = (result: TValidationResult): result is TValidationResult & { isValid: true } =>
result.isValid;
export const isSingleChoiceResult = (
result: TValidationResult
): result is TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle } =>
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
export const isMultiChoiceResult = (
result: TValidationResult
): result is TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti } =>
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceMulti;
export const isPictureSelectionResult = (
result: TValidationResult
): result is TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection } =>
result.isValid && result.type === TSurveyElementTypeEnum.PictureSelection;

View File

@@ -0,0 +1,228 @@
import {
TSurveyConsentElement,
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
TSurveyPictureSelectionElement,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { matchOptionByIdOrLabel } from "./matchers";
import { parseCommaSeparated, parseNumber } from "./parsers";
import { TValidationResult } from "./types";
const invalid = (type?: TSurveyElementTypeEnum): TValidationResult => ({ isValid: false, type });
export const validateOpenText = (): TValidationResult => {
return { isValid: true, type: TSurveyElementTypeEnum.OpenText };
};
export const validateMultipleChoiceSingle = (
element: TSurveyMultipleChoiceElement,
answer: string,
language: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
}
const hasOther = element.choices.at(-1)?.id === "other";
// Try matching by ID or label (new: supports both)
const matchedChoice = matchOptionByIdOrLabel(element.choices, answer, language);
if (matchedChoice) {
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
matchedChoice,
};
}
// If no match and has "other" option, accept any non-empty text as "other" value
if (hasOther) {
const trimmedAnswer = answer.trim();
if (trimmedAnswer !== "") {
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
matchedChoice: null, // null indicates "other" value
};
}
}
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
};
export const validateMultipleChoiceMulti = (
element: TSurveyMultipleChoiceElement,
answer: string,
language: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
const hasOther = element.choices.at(-1)?.id === "other";
const lastChoiceLabel = hasOther ? element.choices.at(-1)?.label?.[language] : undefined;
const answerChoices = parseCommaSeparated(answer);
if (answerChoices.length === 0) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
// Process all answers and collect results
const matched: string[] = [];
const others: string[] = [];
let freeTextOtherCount = 0;
for (const ans of answerChoices) {
const matchedChoice = matchOptionByIdOrLabel(element.choices, ans, language);
if (matchedChoice) {
const label = matchedChoice.label[language];
if (label) {
matched.push(label);
}
continue;
}
// Check if it's the "Other" label itself
if (ans === lastChoiceLabel) {
continue;
}
// It's a free-text "other" value
if (hasOther) {
freeTextOtherCount++;
if (freeTextOtherCount > 1) {
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti); // Only one free-text "other" value allowed
}
others.push(ans);
} else {
// No "other" option and doesn't match any choice
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
}
}
return {
isValid: true,
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
matched,
others,
};
};
export const validateNPS = (answer: string): TValidationResult => {
const answerNumber = parseNumber(answer);
if (answerNumber === null || answerNumber < 0 || answerNumber > 10) {
return invalid(TSurveyElementTypeEnum.NPS);
}
return { isValid: true, type: TSurveyElementTypeEnum.NPS };
};
export const validateConsent = (element: TSurveyConsentElement, answer: string): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.Consent) {
return invalid(TSurveyElementTypeEnum.Consent);
}
if (element.required && answer === "dismissed") {
return invalid(TSurveyElementTypeEnum.Consent);
}
if (answer !== "accepted" && answer !== "dismissed") {
return invalid(TSurveyElementTypeEnum.Consent);
}
return { isValid: true, type: TSurveyElementTypeEnum.Consent };
};
export const validateRating = (element: TSurveyRatingElement, answer: string): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.Rating) {
return invalid(TSurveyElementTypeEnum.Rating);
}
const answerNumber = parseNumber(answer);
if (answerNumber === null || answerNumber < 1 || answerNumber > (element.range ?? 5)) {
return invalid(TSurveyElementTypeEnum.Rating);
}
return { isValid: true, type: TSurveyElementTypeEnum.Rating };
};
export const validatePictureSelection = (
element: TSurveyPictureSelectionElement,
answer: string
): TValidationResult => {
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
const answerChoices = parseCommaSeparated(answer);
const selectedIds: string[] = [];
// Validate all indices and collect selected IDs
for (const ans of answerChoices) {
const num = parseNumber(ans);
if (num === null || num < 1 || num > element.choices.length) {
return invalid(TSurveyElementTypeEnum.PictureSelection);
}
const index = num - 1;
const choice = element.choices[index];
if (choice?.id) {
selectedIds.push(choice.id);
}
}
// Apply allowMulti constraint
const finalIds = element.allowMulti ? selectedIds : selectedIds.slice(0, 1);
return {
isValid: true,
type: TSurveyElementTypeEnum.PictureSelection,
selectedIds: finalIds,
};
};
/**
* Main validation dispatcher
* Routes to appropriate validator based on element type
* Returns validation result with match data for transformers
*/
export const validateElement = (
element: TSurveyElement,
answer: string,
language: string
): TValidationResult => {
// Empty required fields are invalid
if (element.required && (!answer || answer === "")) {
return invalid(element.type);
}
try {
switch (element.type) {
case TSurveyElementTypeEnum.OpenText:
return validateOpenText();
case TSurveyElementTypeEnum.MultipleChoiceSingle:
return validateMultipleChoiceSingle(element, answer, language);
case TSurveyElementTypeEnum.MultipleChoiceMulti:
return validateMultipleChoiceMulti(element, answer, language);
case TSurveyElementTypeEnum.NPS:
return validateNPS(answer);
case TSurveyElementTypeEnum.Consent:
return validateConsent(element, answer);
case TSurveyElementTypeEnum.Rating:
return validateRating(element, answer);
case TSurveyElementTypeEnum.PictureSelection:
return validatePictureSelection(element, answer);
default:
return invalid();
}
} catch {
return invalid(element.type);
}
};

View File

@@ -1,230 +1,2 @@
import { TResponseData } from "@formbricks/types/responses";
import {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
export const getPrefillValue = (
survey: TSurvey,
searchParams: URLSearchParams,
languageId: string
): TResponseData | undefined => {
const prefillAnswer: TResponseData = {};
const questions = getElementsFromBlocks(survey.blocks);
const questionIdxMap = questions.reduce(
(acc, question, idx) => {
acc[question.id] = idx;
return acc;
},
{} as Record<string, number>
);
searchParams.forEach((value, key) => {
if (FORBIDDEN_IDS.includes(key)) return;
const questionId = key;
const questionIdx = questionIdxMap[questionId];
const question = questions[questionIdx];
const answer = value;
if (question) {
if (checkValidity(question, answer, languageId)) {
prefillAnswer[questionId] = transformAnswer(question, answer, languageId);
}
}
});
return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined;
};
const validateOpenText = (): boolean => {
return true;
};
const validateMultipleChoiceSingle = (
question: TSurveyMultipleChoiceElement,
answer: string,
language: string
): boolean => {
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) return false;
const choices = question.choices;
const hasOther = choices[choices.length - 1].id === "other";
if (!hasOther) {
return choices.some((choice) => choice.label[language] === answer);
}
const matchesAnyChoice = choices.some((choice) => choice.label[language] === answer);
if (matchesAnyChoice) {
return true;
}
const trimmedAnswer = answer.trim();
return trimmedAnswer !== "";
};
const validateMultipleChoiceMulti = (question: TSurveyElement, answer: string, language: string): boolean => {
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) return false;
const choices = (
question as TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> }
).choices;
const hasOther = choices[choices.length - 1].id === "other";
const lastChoiceLabel = hasOther ? choices[choices.length - 1].label[language] : undefined;
const answerChoices = answer
.split(",")
.map((ans) => ans.trim())
.filter((ans) => ans !== "");
if (answerChoices.length === 0) {
return false;
}
if (!hasOther) {
return answerChoices.every((ans: string) => choices.some((choice) => choice.label[language] === ans));
}
let freeTextOtherCount = 0;
for (const ans of answerChoices) {
const matchesChoice = choices.some((choice) => choice.label[language] === ans);
if (matchesChoice) {
continue;
}
if (ans === lastChoiceLabel) {
continue;
}
freeTextOtherCount++;
if (freeTextOtherCount > 1) {
return false;
}
}
return true;
};
const validateNPS = (answer: string): boolean => {
try {
const cleanedAnswer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(cleanedAnswer));
return !isNaN(answerNumber) && answerNumber >= 0 && answerNumber <= 10;
} catch {
return false;
}
};
const validateCTA = (question: TSurveyCTAElement, answer: string): boolean => {
if (question.required && answer === "dismissed") return false;
return answer === "clicked" || answer === "dismissed";
};
const validateConsent = (question: TSurveyConsentElement, answer: string): boolean => {
if (question.required && answer === "dismissed") return false;
return answer === "accepted" || answer === "dismissed";
};
const validateRating = (question: TSurveyRatingElement, answer: string): boolean => {
if (question.type !== TSurveyElementTypeEnum.Rating) return false;
const ratingQuestion = question;
try {
const cleanedAnswer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(cleanedAnswer));
return answerNumber >= 1 && answerNumber <= (ratingQuestion.range ?? 5);
} catch {
return false;
}
};
const validatePictureSelection = (answer: string): boolean => {
const answerChoices = answer.split(",");
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
};
const checkValidity = (question: TSurveyElement, answer: string, language: string): boolean => {
if (question.required && (!answer || answer === "")) return false;
const validators: Partial<
Record<TSurveyElementTypeEnum, (q: TSurveyElement, a: string, l: string) => boolean>
> = {
[TSurveyElementTypeEnum.OpenText]: () => validateOpenText(),
[TSurveyElementTypeEnum.MultipleChoiceSingle]: (q, a, l) =>
validateMultipleChoiceSingle(q as TSurveyMultipleChoiceElement, a, l),
[TSurveyElementTypeEnum.MultipleChoiceMulti]: (q, a, l) => validateMultipleChoiceMulti(q, a, l),
[TSurveyElementTypeEnum.NPS]: (_, a) => validateNPS(a),
[TSurveyElementTypeEnum.CTA]: (q, a) => validateCTA(q as TSurveyCTAElement, a),
[TSurveyElementTypeEnum.Consent]: (q, a) => validateConsent(q as TSurveyConsentElement, a),
[TSurveyElementTypeEnum.Rating]: (q, a) => validateRating(q as TSurveyRatingElement, a),
[TSurveyElementTypeEnum.PictureSelection]: (_, a) => validatePictureSelection(a),
};
const validator = validators[question.type];
if (!validator) return false;
try {
return validator(question, answer, language);
} catch {
return false;
}
};
const transformAnswer = (
question: TSurveyElement,
answer: string,
language: string
): string | number | string[] => {
switch (question.type) {
case TSurveyElementTypeEnum.OpenText:
case TSurveyElementTypeEnum.MultipleChoiceSingle: {
return answer;
}
case TSurveyElementTypeEnum.Consent:
case TSurveyElementTypeEnum.CTA: {
if (answer === "dismissed") return "";
return answer;
}
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.NPS: {
const cleanedAnswer = answer.replace(/&/g, ";");
return Number(JSON.parse(cleanedAnswer));
}
case TSurveyElementTypeEnum.PictureSelection: {
const answerChoicesIdx = answer.split(",");
const answerArr: string[] = [];
answerChoicesIdx.forEach((ansIdx) => {
const choice = question.choices[Number(ansIdx) - 1];
if (choice) answerArr.push(choice.id);
});
if (question.allowMulti) return answerArr;
return answerArr.slice(0, 1);
}
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
let ansArr = answer.split(",");
const hasOthers = question.choices[question.choices.length - 1].id === "other";
if (!hasOthers) return ansArr;
// answer can be "a,b,c,d" and options can be a,c,others so we are filtering out the options that are not in the options list and sending these non-existing values as a single string(representing others) like "a", "c", "b,d"
const options = question.choices.map((o) => o.label[language]);
const others = ansArr.filter((a: string) => !options.includes(a));
if (others.length > 0) ansArr = ansArr.filter((a: string) => options.includes(a));
if (others.length > 0) ansArr.push(others.join(","));
return ansArr;
}
default:
return "";
}
};
// Prefilling logic has been moved to @/modules/survey/link/lib/prefill
// This file is kept for any future utility functions

View File

@@ -70,6 +70,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
environment={environment}
project={projectWithRequiredProps}
isTemplatePage={false}
publicDomain={publicDomain}
/>
);

View File

@@ -16,6 +16,7 @@ type TemplateContainerWithPreviewProps = {
environment: Pick<Environment, "id" | "appSetupCompleted">;
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}
/>
)}
</aside>

View File

@@ -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 (
<TemplateContainerWithPreview userId={session.user.id} environment={environment} project={project} />
<TemplateContainerWithPreview
userId={session.user.id}
environment={environment}
project={project}
publicDomain={publicDomain}
/>
);
};

View File

@@ -25,6 +25,7 @@ interface PreviewSurveyProps {
environment: Pick<Environment, "id" | "appSetupCompleted">;
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}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={survey}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -273,6 +276,7 @@ export const PreviewSurvey = ({
</div>
<div className="z-10 w-full rounded-lg border border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}
@@ -345,6 +349,7 @@ export const PreviewSurvey = ({
borderRadius={styling.roundness ?? 8}
background={styling.cardBackgroundColor?.light}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={survey}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -378,6 +383,7 @@ export const PreviewSurvey = ({
</div>
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 focus:outline-none hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
className
)}
{...props}>
@@ -52,7 +52,7 @@ const SelectLabel: React.ComponentType<SelectPrimitive.SelectLabelProps> = React
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
{...props}
/>
));
@@ -65,7 +65,7 @@ const SelectItem: React.ComponentType<SelectPrimitive.SelectItemProps> = React.f
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer items-center rounded-md py-1.5 pr-2 pl-2 text-sm font-medium outline-none select-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>

View File

@@ -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<SurveyContainerProps, "containerId">) =
// 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");

View File

@@ -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}>
<Fragment key={surveyKey}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "app" }}
isBrandingEnabled={project.inAppSurveyBranding}
@@ -192,6 +195,7 @@ export const ThemeStylingPreviewSurvey = ({
key={surveyKey}
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}

View File

@@ -16,6 +16,7 @@ const getHostname = (url) => {
const nextConfig = {
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
basePath: process.env.BASE_PATH || undefined,
output: "standalone",
poweredByHeader: false,
productionBrowserSourceMaps: true,
@@ -61,10 +62,6 @@ const nextConfig = {
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "api-iam.eu.intercom.io",
},
],
},
async redirects() {
@@ -168,7 +165,7 @@ const nextConfig = {
},
{
key: "Content-Security-Policy",
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: http://localhost:9000 https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
},
{
key: "Strict-Transport-Security",
@@ -407,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
},
};
@@ -445,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;

View File

@@ -36,7 +36,6 @@
"@formbricks/surveys": "workspace:*",
"@formbricks/types": "workspace:*",
"@hookform/resolvers": "5.0.1",
"@intercom/messenger-js-sdk": "0.0.14",
"@json2csv/node": "7.0.6",
"@lexical/code": "0.36.2",
"@lexical/link": "0.36.2",
@@ -112,6 +111,7 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
@@ -121,6 +121,7 @@
"react-turnstile": "1.1.4",
"react-use": "17.6.0",
"redis": "4.7.0",
"sanitize-html": "2.17.0",
"server-only": "0.0.1",
"sharp": "0.34.1",
"stripe": "16.12.0",
@@ -148,6 +149,7 @@
"@types/nodemailer": "7.0.2",
"@types/papaparse": "5.3.15",
"@types/qrcode": "1.5.5",
"@types/sanitize-html": "2.16.0",
"@types/testing-library__react": "10.2.0",
"@types/ungap__structured-clone": "1.2.0",
"@vitest/coverage-v8": "3.1.3",

View File

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

View File

@@ -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();

View File

@@ -91,18 +91,6 @@ Adds 'I love Formbricks' as the answer to the open text question:
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?openText_question_id=I%20love%20Formbricks
```
### CTA Question
Accepts only 'dismissed' as answer option. Due to the risk of domain abuse, this value cannot be set to 'clicked' via prefilling:
```txt CTA Question
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?cta_question_id=dismissed
```
<Note>
Due to the risk of domain abuse, this value cannot be set to 'clicked' via prefilling.
</Note>
### Consent Question
Adds 'accepted' as the answer to the Consent question. Alternatively, you can set it to 'dismissed' to skip the question.
@@ -115,11 +103,53 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?consent_question_id=accep
Adds index of the selected image(s) as the answer to the Picture Selection question. The index starts from 1
```txt Picture Selection Question.
```txt Picture Selection Question
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=1%2C2%2C3
```
<Note>All other question types, you currently cannot prefill via the URL.</Note>
## Using Option IDs for Choice-Based Questions
All choice-based question types (Single Select, Multi Select, Picture Selection) now support prefilling with **option IDs** in addition to option labels. This is the recommended approach as it's more reliable and doesn't break when you update option text.
### Benefits of Using Option IDs
- **Stable:** Option IDs don't change when you edit the option label text
- **Language-independent:** Works across all survey languages without modification
- **Reliable:** No issues with special characters or URL encoding of complex text
### How to Find Option IDs
1. Open your survey in the Survey Editor
2. Click on the question you want to prefill
3. Open the **Advanced Settings** at the bottom of the question card
4. Each option shows its unique ID that you can copy
### Examples with Option IDs
**Single Select:**
```sh Single Select with Option ID
https://app.formbricks.com/s/surveyId?questionId=option-abc123
```
**Multi Select:**
```sh Multi Select with Option IDs
https://app.formbricks.com/s/surveyId?questionId=option-1,option-2,option-3
```
**Mixed IDs and Labels:**
You can even mix option IDs and labels in the same URL (though using IDs consistently is recommended):
```sh Mixed Approach
https://app.formbricks.com/s/surveyId?questionId=option-abc,Some%20Label,option-xyz
```
### Backward Compatibility
All existing URLs using option labels continue to work. The system tries to match by option ID first, then falls back to matching by label text if no ID match is found.
## Validation
@@ -127,13 +157,12 @@ Make sure that the answer in the URL matches the expected type for the questions
The URL validation works as follows:
- For Rating or NPS questions, the response is parsed as a number and verified if it's accepted by the schema.
- For CTA type questions, the valid values are "clicked" (main CTA) and "dismissed" (skip CTA).
- For Consent type questions, the valid values are "accepted" (consent given) and "dismissed" (consent not given).
- For Picture Selection type questions, the response is parsed as an array of numbers and verified if it's accepted by the schema.
- All other question types are strings.
- **Rating or NPS questions:** The response is parsed as a number and verified to be within the valid range.
- **Consent type questions:** Valid values are "accepted" (consent given) and "dismissed" (consent not given).
- **Picture Selection questions:** The response is parsed as comma-separated numbers (1-based indices) and verified against available choices.
- **Single/Multi Select questions:** Values can be either option IDs or exact label text. The system tries to match by option ID first, then falls back to label matching.
- **Open Text questions:** Any string value is accepted.
<Note>
If an answer is invalid, the prefilling will be ignored and the question is
presented as if not prefilled.
If an answer is invalid or doesn't match any option, the prefilling will be ignored and the question is presented as if not prefilled.
</Note>

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,5 @@
module.exports = {
extends: ["@formbricks/eslint-config/react.js"],
ignorePatterns: ["**/*.stories.tsx", "**/*.stories.ts", "story-helpers.tsx", "**/*.test.ts"],
};

8
packages/survey-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules
dist
.turbo
coverage
*.log
src/**/*.d.ts
src/**/*.d.ts.map

View File

@@ -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 (
<Button variant="default" size="default">
Click me
</Button>
);
}
```
## 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 <component-name>
```
**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/<component-name>/`
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.

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View File

@@ -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<BaseStylingOptions> & Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
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<StoryProps>()],
};
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: () => (
<div className="w-[600px] space-y-8">
<Consent
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={() => {}}
/>
<Consent
elementId="consent-2"
inputId="consent-input-2"
headline="Privacy Policy"
description="Please review our privacy policy"
checkboxLabel="I agree to the privacy policy"
value
onChange={() => {}}
/>
</div>
),
};

View File

@@ -0,0 +1,105 @@
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;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
function Consent({
elementId,
headline,
description,
inputId,
checkboxLabel,
value = false,
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<ConsentProps>): React.JSX.Element {
const handleCheckboxChange = (checked: boolean): void => {
if (disabled) return;
onChange(checked);
};
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Consent Checkbox */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
<label
htmlFor={`${inputId}-checkbox`}
className={cn(
"bg-input-bg border-input-border text-input-text w-input px-input-x py-input-y rounded-input flex cursor-pointer items-center gap-3 border p-4 transition-colors",
"focus-within:border-ring focus-within:ring-ring/50 font-fontWeight focus-within:shadow-sm",
errorMessage && "border-destructive",
disabled && "cursor-not-allowed opacity-50"
)}
dir={dir}>
<Checkbox
id={`${inputId}-checkbox`}
checked={value}
onCheckedChange={handleCheckboxChange}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
required={required}
/>
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
<span
className="font-input-weight text-input-text flex-1"
style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir}>
{checkboxLabel}
</span>
</label>
</div>
</div>
);
}
export { Consent };

View File

@@ -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<BaseStylingOptions & ButtonStylingOptions> & Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>()],
};
export default meta;
type Story = StoryObj<StoryProps>;
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: () => (
<div className="w-[600px] space-y-8">
<CTA
elementId="cta-1"
inputId="cta-input-1"
headline="Ready to get started?"
description="Click the button below to begin"
buttonLabel="Get Started"
onClick={() => {
alert("clicked");
}}
/>
<CTA
elementId="cta-2"
inputId="cta-input-2"
headline="Learn more about us"
description="Visit our website"
buttonLabel="Visit Website"
buttonUrl="https://example.com"
buttonExternal
onClick={() => {
alert("clicked");
}}
/>
</div>
),
};

View File

@@ -0,0 +1,104 @@
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";
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
function CTA({
elementId,
headline,
description,
inputId,
buttonLabel,
buttonUrl,
buttonExternal = false,
onClick,
required = false,
errorMessage,
dir = "auto",
disabled = false,
buttonVariant = "default",
imageUrl,
videoUrl,
}: Readonly<CTAProps>): React.JSX.Element {
const handleButtonClick = (): void => {
if (disabled) return;
onClick();
if (buttonExternal && buttonUrl) {
window.open(buttonUrl, "_blank")?.focus();
}
};
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* CTA Button */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
{buttonExternal && (
<div className="flex w-full justify-start">
<Button
id={inputId}
type="button"
onClick={handleButtonClick}
disabled={disabled}
className="flex items-center gap-2"
variant={buttonVariant}>
{buttonLabel}
<SquareArrowOutUpRightIcon className="size-4" />
</Button>
</div>
)}
</div>
</div>
);
}
export { CTA };

View File

@@ -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<BaseStylingOptions & Pick<InputLayoutStylingOptions, "inputBorderRadius">> &
Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
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<StoryProps>()],
};
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: () => (
<div className="w-[600px] space-y-8">
<DateElement
elementId="date-1"
inputId="date-1-input"
headline="What is your date of birth?"
description="Please select your date of birth"
onChange={() => {}}
/>
<DateElement
elementId="date-2"
inputId="date-2-input"
headline="When would you like to schedule the appointment?"
value="2024-12-25"
onChange={() => {}}
/>
</div>
),
};
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: () => (
<div className="w-[600px] space-y-8">
<div>
<h3 className="mb-4 text-sm font-semibold">English (en)</h3>
<DateElement
elementId="date-en"
inputId="date-en-input"
headline="What is your date of birth?"
locale="en"
value="2024-12-25"
onChange={() => {}}
/>
</div>
<div>
<h3 className="mb-4 text-sm font-semibold">German (de)</h3>
<DateElement
elementId="date-de"
inputId="date-de-input"
headline="Was ist Ihr Geburtsdatum?"
locale="de"
value="2024-12-25"
onChange={() => {}}
/>
</div>
<div>
<h3 className="mb-4 text-sm font-semibold">French (fr)</h3>
<DateElement
elementId="date-fr"
inputId="date-fr-input"
headline="Quelle est votre date de naissance ?"
locale="fr"
value="2024-12-25"
onChange={() => {}}
/>
</div>
<div>
<h3 className="mb-4 text-sm font-semibold">Spanish (es)</h3>
<DateElement
elementId="date-es"
inputId="date-es-input"
headline="¿Cuál es su fecha de nacimiento?"
locale="es"
value="2024-12-25"
onChange={() => {}}
/>
</div>
<div>
<h3 className="mb-4 text-sm font-semibold">Japanese (ja)</h3>
<DateElement
elementId="date-ja"
inputId="date-ja-input"
headline="生年月日を教えてください"
locale="ja"
value="2024-12-25"
onChange={() => {}}
/>
</div>
<div>
<h3 className="mb-4 text-sm font-semibold">Arabic (ar)</h3>
<DateElement
elementId="date-ar"
inputId="date-ar-input"
headline="ما هو تاريخ ميلادك؟"
locale="ar"
value="2024-12-25"
onChange={() => {}}
/>
</div>
<div>
<h3 className="mb-4 text-sm font-semibold">Russian (ru)</h3>
<DateElement
elementId="date-ru"
inputId="date-ru-input"
headline="Какова ваша дата рождения?"
locale="ru"
value="2024-12-25"
onChange={() => {}}
/>
</div>
<div>
<h3 className="mb-4 text-sm font-semibold">Chinese Simplified (zh-Hans)</h3>
<DateElement
elementId="date-zh"
inputId="date-zh-input"
headline="您的出生日期是什么?"
locale="zh-Hans"
value="2024-12-25"
onChange={() => {}}
/>
</div>
</div>
),
};

View File

@@ -0,0 +1,184 @@
import * as React from "react";
import { Calendar } from "@/components/general/calendar";
import { ElementError } from "@/components/general/element-error";
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;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
function DateElement({
elementId,
headline,
description,
inputId,
value,
onChange,
required = false,
minDate,
maxDate,
dir = "auto",
disabled = false,
locale = "en-US",
errorMessage,
imageUrl,
videoUrl,
}: Readonly<DateElementProps>): React.JSX.Element {
// Initialize date from value string, parsing as local time to avoid timezone issues
const [date, setDate] = React.useState<Date | undefined>(() => {
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("");
}
};
// Get locale for date formatting
const dateLocale = React.useMemo(() => {
return locale ? getDateFnsLocale(locale) : undefined;
}, [locale]);
const startMonth = React.useMemo(() => {
if (!minDate) return undefined;
try {
const [year, month, day] = minDate.split("-").map(Number);
return new Date(year, month - 1, day);
} catch {
return undefined;
}
}, [minDate]);
const endMonth = React.useMemo(() => {
if (!maxDate) return undefined;
try {
const [year, month, day] = maxDate.split("-").map(Number);
return new Date(year, month - 1, day);
} catch {
return undefined;
}
}, [maxDate]);
// Create disabled function for date restrictions
const isDateDisabled = React.useCallback(
(dateToCheck: Date): boolean => {
if (disabled) return true;
const checkAtMidnight = new Date(
dateToCheck.getFullYear(),
dateToCheck.getMonth(),
dateToCheck.getDate()
);
if (startMonth) {
const minAtMidnight = new Date(startMonth.getFullYear(), startMonth.getMonth(), startMonth.getDate());
if (checkAtMidnight < minAtMidnight) return true;
}
if (endMonth) {
const maxAtMidnight = new Date(endMonth.getFullYear(), endMonth.getMonth(), endMonth.getDate());
if (checkAtMidnight > maxAtMidnight) return true;
}
return false;
},
[disabled, endMonth, startMonth]
);
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{/* Calendar - Always visible */}
<div className="w-full">
<Calendar
mode="single"
selected={date}
defaultMonth={date}
captionLayout="dropdown"
startMonth={startMonth}
endMonth={endMonth}
disabled={isDateDisabled}
onSelect={handleDateSelect}
locale={dateLocale}
required={required}
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
/>
</div>
</div>
</div>
);
}
export { DateElement };
export type { DateElementProps };

View File

@@ -0,0 +1,243 @@
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<BaseStylingOptions & InputLayoutStylingOptions> &
Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>()],
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 (
<FileUpload
{...args}
value={value}
onChange={(v) => {
setValue(v);
args.onChange?.(v);
}}
/>
);
},
};
export default meta;
type Story = StoryObj<StoryProps>;
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: () => (
<div className="w-[600px] space-y-8">
<FileUpload
elementId="file-1"
inputId="file-1-input"
headline="Upload your resume"
description="PDF format only"
allowedFileExtensions={[".pdf"]}
onChange={() => {}}
/>
<FileUpload
elementId="file-2"
inputId="file-2-input"
headline="Upload multiple images"
description="You can upload multiple images"
allowMultiple
allowedFileExtensions={[".jpg", ".png", ".gif"]}
onChange={() => {}}
/>
</div>
),
};

View File

@@ -0,0 +1,335 @@
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 { 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<UploadedFileItemProps>): React.JSX.Element {
return (
<div
className={cn(
"border-input-border bg-accent-selected text-input-text rounded-input relative m-1 rounded-md border"
)}>
<div className="absolute top-0 right-0 m-2">
<button
type="button"
onClick={(e) => {
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}`}>
<X className="text-foreground h-5" />
</button>
</div>
<div className="flex flex-col items-center justify-center p-2">
<UploadIcon />
<p
style={{ fontSize: "var(--fb-input-font-size)" }}
className="mt-1 w-full overflow-hidden px-2 text-center overflow-ellipsis whitespace-nowrap text-[var(--foreground)]"
title={file.name}>
{file.name}
</p>
</div>
</div>
);
}
interface UploadedFilesListProps {
files: UploadedFile[];
disabled: boolean;
onDelete: (index: number, e: React.MouseEvent) => void;
}
function UploadedFilesList({
files,
disabled,
onDelete,
}: Readonly<UploadedFilesListProps>): React.JSX.Element | null {
if (files.length === 0) {
return null;
}
return (
<div className="flex w-full flex-col gap-2 p-2">
{files.map((file, index) => (
<UploadedFileItem
key={`${file.name}-${file.url}`}
file={file}
index={index}
disabled={disabled}
onDelete={onDelete}
/>
))}
</div>
);
}
interface UploadAreaProps {
inputId: string;
fileInputRef: React.RefObject<HTMLInputElement | null>;
placeholderText: string;
allowMultiple: boolean;
acceptAttribute?: string;
required: boolean;
disabled: boolean;
dir: "ltr" | "rtl" | "auto";
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
onDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
showUploader: boolean;
}
function UploadArea({
inputId,
fileInputRef,
placeholderText,
allowMultiple,
acceptAttribute,
required,
disabled,
dir,
onFileChange,
onDragOver,
onDrop,
showUploader,
}: Readonly<UploadAreaProps>): React.JSX.Element | null {
if (!showUploader) {
return null;
}
return (
<label
htmlFor={inputId}
onDragOver={onDragOver}
onDrop={onDrop}
className={cn("block w-full", disabled && "cursor-not-allowed")}>
<button
type="button"
onClick={() => {
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">
<Upload className="text-input-text h-6" aria-hidden="true" />
<span
className="text-input-text font-input-weight m-2"
style={{ fontSize: "var(--fb-input-font-size)" }}
id={`${inputId}-label`}>
{placeholderText}
</span>
<input
ref={fileInputRef}
type="file"
id={inputId}
className="sr-only"
multiple={allowMultiple}
accept={acceptAttribute}
onChange={onFileChange}
disabled={disabled}
required={required}
dir={dir}
aria-label="File upload"
aria-describedby={`${inputId}-label`}
/>
</button>
</label>
);
}
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<FileUploadProps>): React.JSX.Element {
const fileInputRef = React.useRef<HTMLInputElement>(null);
// Ensure value is always an array
const uploadedFiles = Array.isArray(value) ? value : [];
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): 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<HTMLLabelElement>): void => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>): 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 (
<div className="w-full space-y-4" id={elementId} dir={dir}>
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
imageAltText={imageAltText}
/>
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<div
className={cn(
"w-input px-input-x py-input-y rounded-input relative flex flex-col items-center justify-center border-2 border-dashed transition-colors",
errorMessage ? "border-destructive" : "border-input-border bg-accent",
disabled && "cursor-not-allowed opacity-50"
)}>
<UploadedFilesList files={uploadedFiles} disabled={disabled} onDelete={handleDeleteFile} />
<div className="w-full">
{isUploading ? (
<div className="flex animate-pulse items-center justify-center rounded-lg py-4">
<p
className="text-muted-foreground font-medium"
style={{ fontSize: "var(--fb-input-font-size)" }}>
Uploading...
</p>
</div>
) : null}
<UploadArea
inputId={inputId}
fileInputRef={fileInputRef}
placeholderText={placeholderText}
allowMultiple={allowMultiple}
acceptAttribute={acceptAttribute}
required={required}
disabled={disabled}
dir={dir}
onFileChange={handleFileChange}
onDragOver={handleDragOver}
onDrop={handleDrop}
showUploader={showUploader}
/>
</div>
</div>
</div>
</div>
);
}
export { FileUpload };
export type { FileUploadProps };

View File

@@ -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<BaseStylingOptions & LabelStylingOptions & InputLayoutStylingOptions & { inputShadow: string }> &
Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
// 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<StoryProps>()],
};
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: () => (
<div className="w-[600px] space-y-8">
<FormField
elementId="form-field-1"
headline="Contact Information"
description="Please provide your contact details"
fields={contactInfoFields}
onChange={() => {}}
/>
<FormField
elementId="form-field-2"
headline="Shipping Address"
description="Where should we ship your order?"
fields={addressFields}
onChange={() => {}}
/>
</div>
),
};

View File

@@ -0,0 +1,152 @@
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<string, string>;
/** Callback function called when any field value changes */
onChange: (value: Record<string, string>) => 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;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
function FormField({
elementId,
headline,
description,
fields,
value = {},
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<FormFieldProps>): 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 (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Form Fields */}
<div className="relative space-y-3">
<ElementError errorMessage={errorMessage} dir={dir} />
{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 (
<div key={field.id} className="space-y-2">
<Label htmlFor={fieldInputId} variant="default">
{fieldRequired ? `${field.label}*` : field.label}
</Label>
<Input
id={fieldInputId}
type={inputType}
value={fieldValue}
onChange={(e) => {
handleFieldChange(field.id, e.target.value);
}}
required={fieldRequired}
disabled={disabled}
dir={dir}
aria-invalid={Boolean(errorMessage) || undefined}
/>
</div>
);
})}
</div>
</div>
);
}
export { FormField };
export type { FormFieldProps };

View File

@@ -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<BaseStylingOptions & LabelStylingOptions & InputLayoutStylingOptions> &
Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
// 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<StoryProps>()],
};
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: () => (
<div className="w-[600px] space-y-8">
<Matrix
elementId="matrix-1"
inputId="matrix-1-input"
headline="Rate each item"
description="Select a value for each row"
rows={defaultRows}
columns={defaultColumns}
onChange={() => {}}
/>
<Matrix
elementId="matrix-2"
inputId="matrix-2-input"
headline="How satisfied are you?"
rows={[
{ id: "feature-1", label: "Feature 1" },
{ id: "feature-2", label: "Feature 2" },
]}
columns={[
{ id: "1", label: "1" },
{ id: "2", label: "2" },
{ id: "3", label: "3" },
{ id: "4", label: "4" },
{ id: "5", label: "5" },
]}
value={{
"feature-1": "4",
"feature-2": "5",
}}
onChange={() => {}}
/>
</div>
),
};

View File

@@ -0,0 +1,181 @@
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<string, string>;
/** Callback function called when selection changes */
onChange: (value: Record<string, 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?: "ltr" | "rtl" | "auto";
/** Whether the options are disabled */
disabled?: boolean;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
function Matrix({
elementId,
headline,
description,
inputId,
rows,
columns,
value = {},
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<MatrixProps>): 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 (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Matrix Table */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{/* Table container with overflow for mobile */}
<div className="overflow-x-auto">
<table className="w-full border-collapse">
{/* Column headers */}
<thead>
<tr>
<th className="p-2 text-left" />
{columns.map((column) => (
<th key={column.id} className="p-2 text-center font-normal">
<Label className="justify-center">{column.label}</Label>
</th>
))}
</tr>
</thead>
{/* Rows */}
<tbody>
{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 (
<RadioGroupPrimitive.Root
key={row.id}
asChild
value={selectedColumnId}
onValueChange={(newColumnId) => {
handleRowChange(row.id, newColumnId);
}}
name={rowGroupId}
disabled={disabled}
required={required}
aria-invalid={Boolean(errorMessage)}>
<tr className={cn("relative", baseBgColor, rowHasError ? "bg-destructive-muted" : "")}>
{/* Row label */}
<th scope="row" className={cn("p-2 align-middle", !rowHasError && "rounded-l-input")}>
<div className="flex flex-col gap-0 leading-none">
<Label>{row.label}</Label>
{rowHasError ? (
<span className="text-destructive text-xs font-normal">Select one option</span>
) : null}
</div>
</th>
{/* Column options for this row */}
{columns.map((column, colIndex) => {
const cellId = `${rowGroupId}-${column.id}`;
const isLastColumn = colIndex === columns.length - 1;
return (
<td
key={column.id}
className={cn(
"p-2 text-center align-middle",
isLastColumn && !rowHasError && "rounded-r-input"
)}>
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
<RadioGroupItem
value={column.id}
required={required}
id={cellId}
disabled={disabled}
aria-label={`${row.label}-${column.label}`}
/>
</Label>
</td>
);
})}
</tr>
</RadioGroupPrimitive.Root>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}
export { Matrix };
export type { MatrixProps };

View File

@@ -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<BaseStylingOptions & LabelStylingOptions & OptionStylingOptions & CheckboxInputStylingOptions> &
Record<string, unknown>;
const meta: Meta<StoryProps> = {
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 (
<MultiSelect
{...args}
value={value}
onChange={(v) => {
setValue(v);
args.onChange?.(v);
}}
otherValue={otherValue}
onOtherValueChange={handleOtherValueChange}
/>
);
},
};
export default meta;
type Story = StoryObj<StoryProps>;
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<StoryProps>()],
};
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: () => (
<div className="w-[600px] space-y-8">
<MultiSelect
elementId="features"
inputId="features-input"
headline="Which features do you use?"
description="Select all that apply"
options={defaultOptions}
onChange={() => {}}
/>
<MultiSelect
elementId="languages"
inputId="languages-input"
headline="What programming languages do you know?"
options={[
{ id: "js", label: "JavaScript" },
{ id: "ts", label: "TypeScript" },
{ id: "python", label: "Python" },
]}
value={["js", "ts"]}
onChange={() => {}}
/>
</div>
),
};
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<string[]>([]);
const [otherValue, setOtherValue] = React.useState<string>("");
return (
<div className="w-[600px]">
<MultiSelect
elementId="multi-select-other"
inputId="multi-select-other-input"
headline="Which features do you use?"
description="Select all that apply"
options={defaultOptions}
value={value}
onChange={setValue}
otherOptionId="other"
otherOptionLabel="Other"
otherOptionPlaceholder="Please specify"
otherValue={otherValue}
onOtherValueChange={setOtherValue}
/>
</div>
);
},
};
export const WithOtherOptionSelected: Story = {
render: () => {
const [value, setValue] = React.useState<string[]>(["option-1", "other"]);
const [otherValue, setOtherValue] = React.useState<string>("Custom feature");
return (
<div className="w-[600px]">
<MultiSelect
elementId="multi-select-other-selected"
inputId="multi-select-other-selected-input"
headline="Which features do you use?"
description="Select all that apply"
options={defaultOptions}
value={value}
onChange={setValue}
otherOptionId="other"
otherOptionLabel="Other"
otherOptionPlaceholder="Please specify"
otherValue={otherValue}
onOtherValueChange={setOtherValue}
/>
</div>
);
},
};
export const DropdownWithOtherOption: Story = {
render: () => {
const [value, setValue] = React.useState<string[]>([]);
const [otherValue, setOtherValue] = React.useState<string>("");
return (
<div className="w-[600px]">
<MultiSelect
elementId="multi-select-dropdown-other"
inputId="multi-select-dropdown-other-input"
headline="Which features do you use?"
description="Select all that apply"
options={defaultOptions}
value={value}
onChange={setValue}
variant="dropdown"
placeholder="Select options..."
otherOptionId="other"
otherOptionLabel="Other"
otherOptionPlaceholder="Please specify"
otherValue={otherValue}
onOtherValueChange={setOtherValue}
/>
</div>
);
},
};

View File

@@ -0,0 +1,562 @@
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[];
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: 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<HTMLInputElement>) => void;
otherOptionPlaceholder: string;
dir: TextDirection;
otherInputRef: React.RefObject<HTMLInputElement | null>;
required: boolean;
}
function DropdownVariant({
inputId,
options,
selectedValues,
handleOptionAdd,
handleOptionRemove,
disabled,
headline,
errorMessage,
displayText,
hasOtherOption,
otherOptionId,
isOtherSelected,
otherOptionLabel,
otherValue,
handleOtherInputChange,
otherOptionPlaceholder,
dir,
otherInputRef,
required,
}: Readonly<DropdownVariantProps>): 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): void => {
if (selectedValues.includes(optionId)) {
handleOptionRemove(optionId);
} else {
handleOptionAdd(optionId);
}
};
return (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className="rounded-input w-full justify-between"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="truncate">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]" align="start">
{options
.filter((option) => option.id !== "none")
.map((option) => {
const isChecked = selectedValues.includes(option.id);
const optionId = `${inputId}-${option.id}`;
return (
<DropdownMenuCheckboxItem
key={option.id}
id={optionId}
checked={isChecked}
onCheckedChange={() => {
handleOptionToggle(option.id);
}}
disabled={disabled}>
<span className={optionLabelClassName}>{option.label}</span>
</DropdownMenuCheckboxItem>
);
})}
{hasOtherOption && otherOptionId ? (
<DropdownMenuCheckboxItem
id={`${inputId}-${otherOptionId}`}
checked={isOtherSelected}
onCheckedChange={() => {
if (isOtherSelected) {
handleOptionRemove(otherOptionId);
} else {
handleOptionAdd(otherOptionId);
}
}}
disabled={disabled}>
<span className={optionLabelClassName}>{otherOptionLabel}</span>
</DropdownMenuCheckboxItem>
) : null}
{options
.filter((option) => option.id === "none")
.map((option) => {
const isChecked = selectedValues.includes(option.id);
const optionId = `${inputId}-${option.id}`;
return (
<DropdownMenuCheckboxItem
key={option.id}
id={optionId}
checked={isChecked}
onCheckedChange={() => {
handleOptionToggle(option.id);
}}
disabled={disabled}>
<span className={optionLabelClassName}>{option.label}</span>
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{isOtherSelected ? (
<Input
ref={otherInputRef}
type="text"
value={otherValue}
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
required={isRequired}
aria-required={required}
dir={dir}
className="w-full"
/>
) : 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<HTMLInputElement>) => void;
otherOptionPlaceholder: string;
dir: TextDirection;
otherInputRef: React.RefObject<HTMLInputElement | null>;
required: boolean;
}
function ListVariant({
inputId,
options,
selectedValues,
value,
handleOptionAdd,
handleOptionRemove,
disabled,
headline,
errorMessage,
hasOtherOption,
otherOptionId,
isOtherSelected,
otherOptionLabel,
otherValue,
handleOtherInputChange,
otherOptionPlaceholder,
dir,
otherInputRef,
required,
}: Readonly<ListVariantProps>): 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 (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="space-y-2" aria-label={headline}>
{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 (
<label
key={option.id}
htmlFor={optionId}
className={cn(getOptionContainerClassName(isChecked, isDisabled), isChecked && "z-10")}>
<span className="flex items-center">
<Checkbox
id={optionId}
name={inputId}
checked={isChecked}
onCheckedChange={(checked) => {
if (checked === true) {
handleOptionAdd(option.id);
} else {
handleOptionRemove(option.id);
}
}}
disabled={isDisabled}
required={isRequired ? isFirstOption : false}
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
</span>
</label>
);
})}
{hasOtherOption && otherOptionId ? (
<div className="space-y-2">
<label
htmlFor={`${inputId}-${otherOptionId}`}
className={cn(
getOptionContainerClassName(isOtherSelected, disabled || isNoneSelected),
isOtherSelected && "z-10"
)}>
<span className="flex items-center">
<Checkbox
id={`${inputId}-${otherOptionId}`}
name={inputId}
checked={isOtherSelected}
onCheckedChange={(checked) => {
if (checked === true) {
handleOptionAdd(otherOptionId);
} else {
handleOptionRemove(otherOptionId);
}
}}
disabled={disabled || isNoneSelected}
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel}
</span>
</span>
{isOtherSelected ? (
<Input
type="text"
value={otherValue}
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
required
aria-required={required}
dir={dir}
className="mt-2 w-full"
ref={otherInputRef}
/>
) : null}
</label>
</div>
) : 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 (
<label
key={option.id}
htmlFor={optionId}
className={cn(getOptionContainerClassName(isChecked, isDisabled), isChecked && "z-10")}>
<span className="flex items-center">
<Checkbox
id={optionId}
name={inputId}
checked={isChecked}
onCheckedChange={(checked) => {
if (checked === true) {
handleOptionAdd(option.id);
} else {
handleOptionRemove(option.id);
}
}}
disabled={isDisabled}
required={false}
aria-invalid={Boolean(errorMessage)}
/>
<span
className={cn("mr-3 ml-3", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
</span>
</label>
);
})}
</fieldset>
</>
);
}
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 = [],
imageUrl,
videoUrl,
}: Readonly<MultiSelectProps>): 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<HTMLInputElement>(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<HTMLInputElement>): 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 (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Options */}
<div className="relative">
{variant === "dropdown" ? (
<DropdownVariant
inputId={inputId}
options={options}
selectedValues={selectedValues}
handleOptionAdd={handleOptionAdd}
handleOptionRemove={handleOptionRemove}
disabled={disabled}
headline={headline}
errorMessage={errorMessage}
displayText={displayText}
hasOtherOption={hasOtherOption}
otherOptionId={otherOptionId}
isOtherSelected={isOtherSelected}
otherOptionLabel={otherOptionLabel}
otherValue={otherValue}
handleOtherInputChange={handleOtherInputChange}
otherOptionPlaceholder={otherOptionPlaceholder}
dir={dir}
otherInputRef={otherInputRef}
required={required}
/>
) : (
<ListVariant
inputId={inputId}
options={options}
selectedValues={selectedValues}
value={value}
handleOptionAdd={handleOptionAdd}
handleOptionRemove={handleOptionRemove}
disabled={disabled}
headline={headline}
errorMessage={errorMessage}
hasOtherOption={hasOtherOption}
otherOptionId={otherOptionId}
isOtherSelected={isOtherSelected}
otherOptionLabel={otherOptionLabel}
otherValue={otherValue}
handleOtherInputChange={handleOtherInputChange}
otherOptionPlaceholder={otherOptionPlaceholder}
dir={dir}
otherInputRef={otherInputRef}
required={required}
/>
)}
</div>
</div>
);
}
export { MultiSelect };
export type { MultiSelectProps };

View File

@@ -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<BaseStylingOptions & LabelStylingOptions> & Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
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<StoryProps>()],
};
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: () => (
<div className="w-[600px] space-y-8">
<NPS
elementId="nps-1"
inputId="nps-input-1"
headline="How likely are you to recommend our product?"
lowerLabel="Not at all likely"
upperLabel="Extremely likely"
onChange={() => {}}
/>
<NPS
elementId="nps-2"
inputId="nps-input-2"
headline="How likely are you to recommend our service?"
description="Please rate from 0 to 10"
value={9}
colorCoding
lowerLabel="Not at all likely"
upperLabel="Extremely likely"
onChange={() => {}}
/>
</div>
),
};

View File

@@ -0,0 +1,209 @@
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;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
function NPS({
elementId,
headline,
description,
inputId,
value,
onChange,
lowerLabel,
upperLabel,
colorCoding = false,
required = false,
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<NPSProps>): React.JSX.Element {
const [hoveredValue, setHoveredValue] = React.useState<number | null>(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
<label
key={number}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown(number)}
className={cn(
"text-input-text font-input font-input-weight relative flex w-full cursor-pointer items-center justify-center overflow-hidden transition-colors focus:outline-none",
borderClasses,
isSelected
? "bg-brand-20 border-brand z-10 -ml-[1px] border-2 first:ml-0"
: "border-input-border bg-input-bg",
borderRadiusClasses,
isHovered && !isSelected && "bg-input-selected-bg",
colorCoding ? "min-h-[47px]" : "min-h-[41px]",
disabled && "cursor-not-allowed opacity-50",
"focus:border-brand focus:border-2"
)}
onMouseEnter={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onMouseLeave={() => {
setHoveredValue(null);
}}
onFocus={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onBlur={() => {
setHoveredValue(null);
}}>
{colorCoding ? (
<div className={cn("absolute top-0 left-0 h-[6px] w-full", getNPSOptionColor(number))} />
) : null}
<input
type="radio"
name={inputId}
value={number}
checked={isSelected}
onChange={() => {
handleSelect(number);
}}
disabled={disabled}
required={required}
className="sr-only"
aria-label={`Rate ${String(number)} out of 10`}
/>
<span className="text-sm">{number}</span>
</label>
);
};
// Generate NPS options (0-10)
const npsOptions = Array.from({ length: 11 }, (_, i) => i);
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* NPS Options */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full px-[2px]">
<legend className="sr-only">NPS rating options</legend>
<div className="flex w-full">{npsOptions.map((number) => renderNPSOption(number))}</div>
{/* Labels */}
{(lowerLabel ?? upperLabel) ? (
<div className="mt-2 flex justify-between gap-8 px-1.5">
{lowerLabel ? (
<Label variant="default" className="max-w-[50%] text-xs leading-6" dir={dir}>
{lowerLabel}
</Label>
) : null}
{upperLabel ? (
<Label variant="default" className="max-w-[50%] text-right text-xs leading-6" dir={dir}>
{upperLabel}
</Label>
) : null}
</div>
) : null}
</fieldset>
</div>
</div>
);
}
export { NPS };
export type { NPSProps };

View File

@@ -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<BaseStylingOptions & InputLayoutStylingOptions> &
Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
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<StoryProps>()],
};
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: () => (
<div className="w-[600px] space-y-8">
<OpenText
elementId="name"
inputId="name"
headline="What's your name?"
placeholder="Enter your name"
required
onChange={() => {}}
/>
<OpenText
elementId="email"
inputId="email"
headline="What's your email?"
inputType="email"
placeholder="email@example.com"
required
onChange={() => {}}
/>
<OpenText
elementId="bio"
inputId="bio"
headline="Tell us about yourself"
description="Optional: Share a bit about your background"
placeholder="Your bio..."
longAnswer
rows={4}
onChange={() => {}}
/>
</div>
),
};

View File

@@ -0,0 +1,121 @@
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;
imageUrl?: string;
videoUrl?: string;
}
function OpenText({
elementId,
headline,
description,
placeholder,
value = "",
inputId,
onChange,
required = false,
longAnswer = false,
inputType = "text",
charLimit,
errorMessage,
dir = "auto",
rows = 3,
disabled = false,
imageUrl,
videoUrl,
}: Readonly<OpenTextProps>): React.JSX.Element {
const [currentLength, setCurrentLength] = useState(value.length);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): 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 (
<span className={cn("text-xs", isOverLimit ? "font-semibold text-red-500" : "text-brand")}>
{currentLength}/{charLimit.max}
</span>
);
};
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Input or Textarea */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
<div className="space-y-1">
{longAnswer ? (
<Textarea
id={inputId}
placeholder={placeholder}
value={value}
onChange={handleChange}
required={required}
dir={dir}
rows={rows}
disabled={disabled}
aria-invalid={Boolean(errorMessage) || undefined}
minLength={charLimit?.min}
maxLength={charLimit?.max}
/>
) : (
<Input
id={inputId}
type={inputType}
placeholder={placeholder}
value={value}
onChange={handleChange}
required={required}
dir={dir}
disabled={disabled}
aria-invalid={Boolean(errorMessage) || undefined}
minLength={charLimit?.min}
maxLength={charLimit?.max}
/>
)}
{renderCharLimit()}
</div>
</div>
</div>
);
}
export { OpenText };
export type { OpenTextProps };

View File

@@ -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<BaseStylingOptions & { optionBorderRadius: string }> &
Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
// 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<StoryProps>()],
};
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<string | string[] | undefined>(undefined);
const [value2, setValue2] = React.useState<string | string[]>(["option-1", "option-3"]);
return (
<div className="w-[600px] space-y-8">
<PictureSelect
elementId="picture-1"
inputId="picture-1-input"
headline="Which image do you prefer?"
description="Select one image"
options={defaultOptions}
value={value1}
onChange={setValue1}
/>
<PictureSelect
elementId="picture-2"
inputId="picture-2-input"
headline="Select all images you like"
description="You can select multiple images"
options={defaultOptions}
allowMulti
value={value2}
onChange={setValue2}
/>
</div>
);
},
};

View File

@@ -0,0 +1,211 @@
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;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
function PictureSelect({
elementId,
headline,
description,
inputId,
options,
value,
onChange,
allowMulti = false,
required = false,
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<PictureSelectProps>): 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 (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Picture Grid - 2 columns */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
{allowMulti ? (
<div className="grid grid-cols-2 gap-2">
{options.map((option) => {
const isSelected = (selectedValues as string[]).includes(option.id);
const optionId = `${inputId}-${option.id}`;
return (
<label
key={option.id}
htmlFor={optionId}
className={cn(
"rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all",
disabled && "cursor-not-allowed opacity-50"
)}>
{/* Image container with border when selected */}
<div
className={cn(
"rounded-option absolute inset-[2px] overflow-hidden",
isSelected && "border-brand border-4 border-solid"
)}>
<img
src={option.imageUrl}
alt={option.alt ?? `Option ${option.id}`}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
{/* Selection indicator - Checkbox for multi select */}
<div
className="absolute top-[5%] right-[5%]"
onPointerDown={(e) => {
e.stopPropagation();
}}>
<Checkbox
id={optionId}
checked={isSelected}
onCheckedChange={(checked) => {
handleMultiSelectChange(option.id, checked === true);
}}
disabled={disabled}
className="h-4 w-4"
aria-label={option.alt ?? `Select ${option.id}`}
/>
</div>
</label>
);
})}
</div>
) : (
<RadioGroupPrimitive.Root
value={selectedValues as string}
onValueChange={handleSingleSelectChange}
disabled={disabled}
className="grid grid-cols-2 gap-2">
{options.map((option) => {
const optionId = `${inputId}-${option.id}`;
const isSelected = selectedValues === option.id;
return (
<label
key={option.id}
htmlFor={optionId}
className={cn(
"rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all",
disabled && "cursor-not-allowed opacity-50"
)}>
{/* Image container with border when selected */}
<div
className={cn(
"rounded-option absolute inset-[2px] overflow-hidden",
isSelected && "border-brand border-4 border-solid"
)}>
<img
src={option.imageUrl}
alt={option.alt ?? `Option ${option.id}`}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
{/* Selection indicator - Radio button for single select */}
<div
className="absolute top-[5%] right-[5%]"
onPointerDown={(e) => {
e.stopPropagation();
}}>
<RadioGroupItem
value={option.id}
id={optionId}
disabled={disabled}
className="h-4 w-4 bg-white"
aria-label={option.alt ?? `Select ${option.id}`}
/>
</div>
</label>
);
})}
</RadioGroupPrimitive.Root>
)}
</div>
</div>
);
}
export { PictureSelect };
export type { PictureSelectProps };

View File

@@ -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<BaseStylingOptions & OptionStylingOptions> & Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
// 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<StoryProps>()],
};
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: () => (
<div className="w-[600px] space-y-8">
<Ranking
elementId="ranking-1"
inputId="ranking-input-1"
headline="Rank these features by priority"
options={defaultOptions}
onChange={() => {}}
/>
<Ranking
elementId="ranking-2"
inputId="ranking-input-2"
headline="Rank these products by preference"
description="Click items to rank them"
options={[
{ id: "1", label: "Product A" },
{ id: "2", label: "Product B" },
{ id: "3", label: "Product C" },
]}
value={["2", "1"]}
onChange={() => {}}
/>
</div>
),
};

View File

@@ -0,0 +1,283 @@
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;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
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<RankingItemProps>): React.ReactNode {
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 (
<div
className={cn(
"rounded-option flex h-12 cursor-pointer items-center border transition-all",
paddingClass,
"bg-option-bg border-option-border",
"hover:bg-option-hover-bg focus-within:border-brand focus-within:bg-option-selected-bg focus-within:shadow-sm",
isRanked && "bg-option-selected-bg border-brand",
disabled && "cursor-not-allowed opacity-50"
)}>
<button
type="button"
onClick={() => {
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`}>
<span
className={cn(
"border-brand flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold",
isRanked
? "bg-brand text-white"
: "group-hover:bg-background group-hover:text-foreground border-dashed text-transparent"
)}>
{displayNumber}
</span>
<span
className="font-option text-option font-option-weight text-option-label shrink grow text-start"
dir={dir}>
{item.label}
</span>
</button>
{/* Up/Down buttons for ranked items */}
{isRanked ? (
<div className={cn("border-option-border flex h-full grow-0 flex-col", borderClass)}>
<button
type="button"
tabIndex={isFirst ? -1 : 0}
onClick={(e) => {
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
)}>
<ChevronUp className="h-5 w-5" />
</button>
<button
type="button"
tabIndex={isLast ? -1 : 0}
onClick={(e) => {
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
)}>
<ChevronDown className="h-5 w-5" />
</button>
</div>
) : null}
</div>
);
}
function Ranking({
elementId,
headline,
description,
inputId,
options,
value = [],
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<RankingProps>): 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 (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Ranking Options */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full" dir={dir}>
<legend className="sr-only">Ranking options</legend>
<div className="space-y-2" ref={parent as React.Ref<HTMLDivElement>}>
{allItems.map((item) => (
<RankingItem
key={item.id}
item={item}
rankedIds={rankedIds}
onItemClick={handleItemClick}
onMove={handleMove}
disabled={disabled}
dir={dir}
/>
))}
</div>
</fieldset>
</div>
</div>
);
}
export { Ranking };
export type { RankingProps };

View File

@@ -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<BaseStylingOptions & LabelStylingOptions> & Record<string, unknown>;
const meta: Meta<StoryProps> = {
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<StoryProps>;
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<StoryProps>()],
};
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<number | undefined>(undefined);
const [value2, setValue2] = React.useState<number | undefined>(undefined);
const [value3, setValue3] = React.useState<number | undefined>(undefined);
return (
<div className="w-[600px] space-y-8">
<Rating
elementId="rating-1"
inputId="rating-input-1"
headline="How satisfied are you with our service?"
scale="number"
range={5}
lowerLabel="Not satisfied"
upperLabel="Very satisfied"
value={value1}
onChange={setValue1}
/>
<Rating
elementId="rating-2"
inputId="rating-input-2"
headline="Rate this product"
description="Please rate from 1 to 5 stars"
scale="star"
range={5}
value={value2}
onChange={setValue2}
/>
<Rating
elementId="rating-3"
inputId="rating-input-3"
headline="How do you feel?"
scale="smiley"
range={5}
colorCoding
value={value3}
onChange={setValue3}
/>
</div>
);
},
};

View File

@@ -0,0 +1,455 @@
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 = [
<TiredFace key="tired-face" className={active ? activeColor : inactiveColor} />,
<WearyFace key="weary-face" className={active ? activeColor : inactiveColor} />,
<PerseveringFace key="persevering-face" className={active ? activeColor : inactiveColor} />,
<FrowningFace key="frowning-face" className={active ? activeColor : inactiveColor} />,
<ConfusedFace key="confused-face" className={active ? activeColor : inactiveColor} />,
<NeutralFace key="neutral-face" className={active ? activeColor : inactiveColor} />,
<SlightlySmilingFace key="slightly-smiling-face" className={active ? activeColor : inactiveColor} />,
<SmilingFaceWithSmilingEyes
key="smiling-face-with-smiling-eyes"
className={active ? activeColor : inactiveColor}
/>,
<GrinningFaceWithSmilingEyes
key="grinning-face-with-smiling-eyes"
className={active ? activeColor : inactiveColor}
/>,
<GrinningSquintingFace key="grinning-squinting-face" className={active ? activeColor : inactiveColor} />,
];
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;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
function Rating({
elementId,
headline,
description,
inputId,
scale,
range,
value,
onChange,
lowerLabel,
upperLabel,
colorCoding = false,
required = false,
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
}: Readonly<RatingProps>): React.JSX.Element {
const [hoveredValue, setHoveredValue] = React.useState<number | null>(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
<label
key={number}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown(number)}
className={cn(
"text-input-text font-input font-input-weight relative flex w-full cursor-pointer items-center justify-center overflow-hidden transition-colors focus:outline-none",
borderClasses,
isSelected
? "bg-brand-20 border-brand z-10 -ml-[1px] border-2 first:ml-0"
: "border-input-border bg-input-bg",
borderRadiusClasses,
isHovered && !isSelected && "bg-input-selected-bg",
colorCoding ? "min-h-[47px]" : "min-h-[41px]",
disabled && "cursor-not-allowed opacity-50",
"focus:border-brand focus:border-2"
)}
onMouseEnter={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onMouseLeave={() => {
setHoveredValue(null);
}}
onFocus={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onBlur={() => {
setHoveredValue(null);
}}>
{colorCoding ? (
<div
className={cn("absolute top-0 left-0 h-[6px] w-full", getRatingNumberOptionColor(range, number))}
/>
) : null}
<input
type="radio"
name={inputId}
value={number}
checked={isSelected}
onChange={() => {
handleSelect(number);
}}
disabled={disabled}
required={required}
className="sr-only"
aria-label={`Rate ${String(number)} out of ${String(range)}`}
/>
<span className="text-sm">{number}</span>
</label>
);
};
// 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
<label
key={number}
className={cn(
"flex min-h-[48px] flex-1 cursor-pointer items-center justify-center transition-opacity",
disabled && "cursor-not-allowed opacity-50"
)}
onMouseEnter={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onMouseLeave={() => {
setHoveredValue(null);
}}
onFocus={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onBlur={() => {
setHoveredValue(null);
}}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown(number)}>
<input
type="radio"
name={inputId}
value={number}
checked={isSelected}
onChange={() => {
handleSelect(number);
}}
disabled={disabled}
required={required}
className="sr-only"
aria-label={`Rate ${String(number)} out of ${String(range)} stars`}
/>
<div className="pointer-events-none flex w-full max-w-[74px] items-center justify-center">
{isActive ? (
<Star className="h-full w-full fill-yellow-400 text-yellow-400 transition-colors" />
) : (
<Star className="h-full w-full fill-slate-300 text-slate-300 transition-colors" />
)}
</div>
</label>
);
};
// 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
<label
key={number}
tabIndex={disabled ? -1 : 0}
onKeyDown={handleKeyDown(number)}
className={cn(
"relative flex max-h-16 min-h-9 w-full cursor-pointer justify-center transition-colors focus:outline-none",
isActive
? "stroke-brand text-brand"
: "stroke-muted-foreground text-muted-foreground focus:border-accent focus:border-2",
disabled && "cursor-not-allowed opacity-50"
)}
onMouseEnter={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onMouseLeave={() => {
setHoveredValue(null);
}}
onFocus={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onBlur={() => {
setHoveredValue(null);
}}>
<input
type="radio"
name={inputId}
value={number}
checked={isSelected}
onChange={() => {
handleSelect(number);
}}
disabled={disabled}
required={required}
className="sr-only"
aria-label={`Rate ${String(number)} out of ${String(range)}`}
/>
<div className="text-input-text pointer-events-none h-full w-full max-w-[74px] object-contain">
<RatingSmiley active={isActive} idx={index} range={range} addColors={colorCoding} />
</div>
</label>
);
};
// Generate rating options
const ratingOptions = Array.from({ length: range }, (_, i) => i + 1);
return (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Rating Options */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
<fieldset className="w-full">
<legend className="sr-only">Rating options</legend>
<div className="flex w-full px-[2px]">
{ratingOptions.map((number, index) => {
if (scale === "number") {
return renderNumberOption(number, ratingOptions.length);
} else if (scale === "star") {
return renderStarOption(number);
}
return renderSmileyOption(number, index);
})}
</div>
{/* Labels */}
{(lowerLabel ?? upperLabel) ? (
<div className="mt-4 flex justify-between gap-8 px-1.5">
{lowerLabel ? (
<Label variant="default" className="max-w-[50%] text-xs leading-6" dir={dir}>
{lowerLabel}
</Label>
) : null}
{upperLabel ? (
<Label variant="default" className="max-w-[50%] text-right text-xs leading-6" dir={dir}>
{upperLabel}
</Label>
) : null}
</div>
) : null}
</fieldset>
</div>
</div>
);
}
export { Rating };
export type { RatingProps };

View File

@@ -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<BaseStylingOptions & LabelStylingOptions & OptionStylingOptions> &
Record<string, unknown>;
const meta: Meta<StoryProps> = {
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 (
<SingleSelect
{...args}
value={value}
onChange={(v) => {
setValue(v);
args.onChange?.(v);
}}
otherValue={otherValue}
onOtherValueChange={handleOtherValueChange}
/>
);
},
};
export default meta;
type Story = StoryObj<StoryProps>;
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<StoryProps>()],
};
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<string | undefined>(undefined);
const [value2, setValue2] = React.useState<string>("js");
return (
<div className="w-[600px] space-y-8">
<SingleSelect
elementId="preference"
inputId="preference-input"
headline="Which option do you prefer?"
description="Select one option"
options={defaultOptions}
value={value1}
onChange={setValue1}
/>
<SingleSelect
elementId="language"
inputId="language-input"
headline="What is your favorite programming language?"
options={[
{ id: "js", label: "JavaScript" },
{ id: "ts", label: "TypeScript" },
{ id: "python", label: "Python" },
]}
value={value2}
onChange={setValue2}
/>
</div>
);
},
};
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<string | undefined>(undefined);
const [otherValue, setOtherValue] = React.useState<string>("");
return (
<div className="w-[600px]">
<SingleSelect
elementId="single-select-other"
inputId="single-select-other-input"
headline="Which option do you prefer?"
description="Select one option"
options={defaultOptions}
value={value}
onChange={setValue}
otherOptionId="other"
otherOptionLabel="Other"
otherOptionPlaceholder="Please specify"
otherValue={otherValue}
onOtherValueChange={setOtherValue}
/>
</div>
);
},
};
export const WithOtherOptionSelected: Story = {
render: () => {
const [value, setValue] = React.useState<string>("other");
const [otherValue, setOtherValue] = React.useState<string>("Custom option");
return (
<div className="w-[600px]">
<SingleSelect
elementId="single-select-other-selected"
inputId="single-select-other-selected-input"
headline="Which option do you prefer?"
description="Select one option"
options={defaultOptions}
value={value}
onChange={setValue}
otherOptionId="other"
otherOptionLabel="Other"
otherOptionPlaceholder="Please specify"
otherValue={otherValue}
onOtherValueChange={setOtherValue}
/>
</div>
);
},
};
export const DropdownWithOtherOption: Story = {
render: () => {
const [value, setValue] = React.useState<string | undefined>(undefined);
const [otherValue, setOtherValue] = React.useState<string>("");
return (
<div className="w-[600px]">
<SingleSelect
elementId="single-select-dropdown-other"
inputId="single-select-dropdown-other-input"
headline="Which option do you prefer?"
description="Select one option"
options={defaultOptions}
value={value}
onChange={setValue}
variant="dropdown"
placeholder="Choose an option..."
otherOptionId="other"
otherOptionLabel="Other"
otherOptionPlaceholder="Please specify"
otherValue={otherValue}
onOtherValueChange={setOtherValue}
/>
</div>
);
},
};
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<StoryProps>()],
};
export const WithContainerStylingAndOther: Story = {
render: () => {
const [value, setValue] = React.useState<string | undefined>(undefined);
const [otherValue, setOtherValue] = React.useState<string>("");
return (
<div className="w-[600px]">
<SingleSelect
elementId="container-styling-other"
inputId="container-styling-other-input"
headline="Select an option"
description="Options have containers, including the 'Other' option"
options={defaultOptions}
value={value}
onChange={setValue}
otherOptionId="other"
otherOptionLabel="Other"
otherOptionPlaceholder="Please specify"
otherValue={otherValue}
onOtherValueChange={setOtherValue}
/>
</div>
);
},
};

View File

@@ -0,0 +1,328 @@
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;
/** Image URL to display above the headline */
imageUrl?: string;
/** Video URL to display above the headline */
videoUrl?: string;
}
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,
imageUrl,
videoUrl,
}: Readonly<SingleSelectProps>): 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<HTMLInputElement>(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<HTMLInputElement>): 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 (
<div className="w-full space-y-4" id={elementId} dir={dir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
/>
{/* Options */}
<div className="space-y-3">
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={dir} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className="rounded-input w-full justify-between"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="truncate">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)]"
align="start">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{options
.filter((option) => option.id !== "none")
.map((option) => {
const optionId = `${inputId}-${option.id}`;
return (
<DropdownMenuRadioItem
key={option.id}
value={option.id}
id={optionId}
disabled={disabled}>
<span className={optionLabelClassName}>{option.label}</span>
</DropdownMenuRadioItem>
);
})}
{hasOtherOption && otherOptionId ? (
<DropdownMenuRadioItem
value={otherOptionId}
id={`${inputId}-${otherOptionId}`}
disabled={disabled}>
<span className={optionLabelClassName}>{otherValue || otherOptionLabel}</span>
</DropdownMenuRadioItem>
) : null}
{options
.filter((option) => option.id === "none")
.map((option) => {
const optionId = `${inputId}-${option.id}`;
return (
<DropdownMenuRadioItem
key={option.id}
value={option.id}
id={optionId}
disabled={disabled}>
<span className={optionLabelClassName}>{option.label}</span>
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{isOtherSelected ? (
<Input
ref={otherInputRef}
type="text"
value={otherValue}
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
dir={dir}
className="w-full"
/>
) : null}
</>
) : (
<RadioGroup
name={inputId}
value={selectedValue}
onValueChange={onChange}
disabled={disabled}
errorMessage={errorMessage}
required={required}
className="w-full gap-0 space-y-2">
{options
.filter((option) => option.id !== "none")
.map((option) => {
const optionId = `${inputId}-${option.id}`;
const isSelected = selectedValue === option.id;
return (
<label
key={option.id}
htmlFor={optionId}
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
value={option.id}
id={optionId}
disabled={disabled}
required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
</span>
</label>
);
})}
{hasOtherOption && otherOptionId ? (
<label
htmlFor={`${inputId}-${otherOptionId}`}
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
value={otherOptionId}
id={`${inputId}-${otherOptionId}`}
disabled={disabled}
required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel}
</span>
</span>
{isOtherSelected ? (
<Input
ref={otherInputRef}
type="text"
value={otherValue}
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
aria-required={required}
dir={dir}
className="mt-2 w-full"
required={required}
/>
) : null}
</label>
) : null}
{options
.filter((option) => option.id === "none")
.map((option) => {
const optionId = `${inputId}-${option.id}`;
const isSelected = selectedValue === option.id;
return (
<label
key={option.id}
htmlFor={optionId}
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
<span className="flex items-center">
<RadioGroupItem
value={option.id}
id={optionId}
disabled={disabled}
required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{option.label}
</span>
</span>
</label>
);
})}
</RadioGroup>
)}
</div>
</div>
);
}
export { SingleSelect };
export type { SingleSelectProps };

View File

@@ -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<typeof Alert> = {
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<typeof Alert>;
export const Default: Story = {
render: () => (
<Alert>
<AlertTitle>Alert Title</AlertTitle>
<AlertDescription>This is a default alert message.</AlertDescription>
</Alert>
),
};
export const Destructive: Story = {
render: () => (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>Something went wrong. Please try again.</AlertDescription>
</Alert>
),
};
export const DestructiveWithIcon: Story = {
render: () => (
<Alert variant="destructive">
<TriangleAlertIcon />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Something went wrong. Please try again.</AlertDescription>
</Alert>
),
};
export const WithTitleOnly: Story = {
render: () => (
<Alert>
<AlertTitle>Important Notice</AlertTitle>
</Alert>
),
};
export const WithDescriptionOnly: Story = {
render: () => (
<Alert>
<AlertDescription>This alert only has a description.</AlertDescription>
</Alert>
),
};

View File

@@ -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<typeof alertVariants>): React.JSX.Element {
return (
<div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element {
return (
<div
data-slot="alert-title"
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -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<typeof Button>;
type StoryProps = ButtonProps & StylingOptions;
const meta: Meta<StoryProps> = {
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<StoryProps>;
// Decorator to apply CSS variables from story args
const withCSSVariables: Decorator<StoryProps> = (
Story: React.ComponentType,
context: StoryContext<StoryProps>
) => {
// 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<string, string | undefined> = {
"--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 (
<div style={cssVarStyle}>
<Story />
</div>
);
};
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",
},
};

View File

@@ -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-button 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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size }), className)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,238 @@
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<HTMLDivElement>;
children?: React.ReactNode;
}>): React.JSX.Element {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
}
function CalendarChevron({
className,
orientation,
...props
}: Readonly<{
className?: string;
orientation?: "left" | "right" | "up" | "down";
}>): React.JSX.Element {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
}
if (orientation === "right") {
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
}
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
}
function CalendarWeekNumber({
children,
...props
}: Readonly<{ children?: React.ReactNode }>): React.JSX.Element {
return (
<td {...props}>
<div className="flex h-[--cell-size] w-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
);
}
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
locale,
startMonth,
endMonth,
...props
}: Readonly<
React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["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]);
const resolvedStartMonth = React.useMemo(() => {
if (startMonth) return startMonth;
if (captionLayout === "dropdown") {
return new Date(new Date().getFullYear() - 100, 0);
}
return undefined;
}, [startMonth, captionLayout]);
const resolvedEndMonth = React.useMemo(() => {
if (endMonth) return endMonth;
if (captionLayout === "dropdown") {
return new Date(new Date().getFullYear() + 100, 11);
}
return undefined;
}, [endMonth, captionLayout]);
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={resolvedLocale}
startMonth={resolvedStartMonth}
endMonth={resolvedEndMonth}
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-brand-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<typeof DayButton>): React.JSX.Element {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end ? !modifiers.range_middle : null
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -0,0 +1,90 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Checkbox } from "./checkbox";
import { Label } from "./label";
const meta: Meta<typeof Checkbox> = {
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<typeof meta>;
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: () => (
<div className="flex items-center space-x-2">
<Checkbox id="terms" />
<Label htmlFor="terms">Accept terms and conditions</Label>
</div>
),
};
export const WithLabelChecked: Story = {
render: () => (
<div className="flex items-center space-x-2">
<Checkbox id="terms-checked" checked />
<Label htmlFor="terms-checked">Accept terms and conditions</Label>
</div>
),
};
export const WithLabelDisabled: Story = {
render: () => (
<div className="flex items-center space-x-2">
<Checkbox id="terms-disabled" disabled />
<Label htmlFor="terms-disabled">Accept terms and conditions</Label>
</div>
),
};

View File

@@ -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<typeof CheckboxPrimitive.Root>): React.JSX.Element {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"border-input-border dark:bg-input/30 data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground dark:data-[state=checked]:bg-brand data-[state=checked]:border-brand focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-input-text peer size-4 shrink-0 rounded-[4px] border bg-white shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="text-brand-foreground flex items-center justify-center text-current transition-none">
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -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<typeof DropdownMenu> = {
title: "UI-package/General/DropdownMenu",
component: DropdownMenu,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
};
export default meta;
type Story = StoryObj<typeof DropdownMenu>;
export const Default: Story = {
render: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Open Menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<UserIcon />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};
export const WithShortcuts: Story = {
render: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Open Menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<UserIcon />
Profile
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon />
Billing
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon />
Settings
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon />
Log out
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};
export const WithCheckboxes: Story = {
render: () => {
const [showStatusBar, setShowStatusBar] = React.useState(true);
const [showActivityBar, setShowActivityBar] = React.useState(false);
const [showPanel, setShowPanel] = React.useState(false);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">View Options</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
Status Bar
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={showActivityBar} onCheckedChange={setShowActivityBar}>
Activity Bar
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
Panel
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
};
export const WithRadioGroup: Story = {
render: () => {
const [position, setPosition] = React.useState("bottom");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Position</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Panel Position</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={position} onValueChange={setPosition}>
<DropdownMenuRadioItem value="top">Top</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bottom">Bottom</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="right">Right</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
},
};
export const WithSubmenu: Story = {
render: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">Open Menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<UserIcon />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon />
Billing
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UsersIcon />
Invite users
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Email</DropdownMenuItem>
<DropdownMenuItem>Message</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>More...</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
};

View File

@@ -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<React.ComponentProps<typeof DropdownMenuPrimitive.Root>>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Portal>>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Content>>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Sub>>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

Some files were not shown because too many files have changed in this diff Show More