chore: new question components (#6947)

Co-authored-by: Matti Nannt <matti@formbricks.com>
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Dhruwang Jariwala
2025-12-12 19:50:16 +05:30
committed by GitHub
parent f1b9f45f18
commit 6f85e57d1a
73 changed files with 11323 additions and 1525 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

@@ -1,8 +1,12 @@
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";
import type { Plugin } from "vite";
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.
@@ -29,5 +33,92 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
async viteFinal(config) {
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
const webPath = resolve(__dirname, "../../web");
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],
};
// Create a more robust plugin to handle @/ alias resolution
const aliasPlugin: Plugin = {
name: "resolve-path-alias",
enforce: "pre",
resolveId(id, importer) {
if (!id.startsWith("@/")) {
return null;
}
const pathWithoutAlias = id.replace(/^@\//, "");
const normalizedImporter = importer ? importer.replace(/\\/g, "/") : "";
// Determine base path based on importer location
let basePath: string | null = null;
if (normalizedImporter.includes("packages/survey-ui")) {
basePath = surveyUiPath;
} else if (normalizedImporter.includes("apps/web") || normalizedImporter.includes("web/modules")) {
basePath = webPath;
}
// If we can't determine from importer, try to find the file
if (!basePath) {
// Try survey-ui first
const surveyUiResolved = resolve(surveyUiPath, pathWithoutAlias);
const fs = require("fs");
const extensions = ["", ".ts", ".tsx", ".js", ".jsx"];
for (const ext of extensions) {
if (fs.existsSync(surveyUiResolved + ext)) {
return surveyUiResolved + ext;
}
}
// Fall back to web
basePath = webPath;
}
const resolved = resolve(basePath, pathWithoutAlias);
// Try to resolve with extensions
const fs = require("fs");
const extensions = ["", ".ts", ".tsx", ".js", ".jsx"];
for (const ext of extensions) {
const withExt = resolved + ext;
if (fs.existsSync(withExt)) {
return withExt;
}
}
// Return without extension - Vite will handle it
return resolved;
},
};
// Add the plugin
config.plugins = [aliasPlugin, ...(config.plugins || [])];
// Configure resolve options
config.resolve = config.resolve || {};
config.resolve.dedupe = config.resolve.dedupe || [];
// Keep existing aliases but remove @/ since plugin handles it
const existingAlias = config.resolve.alias || {};
if (typeof existingAlias === "object" && !Array.isArray(existingAlias)) {
const aliasObj = existingAlias as Record<string, string>;
const { "@": _, ...otherAliases } = aliasObj;
config.resolve.alias = {
...otherAliases,
};
}
return config;
},
};
export default config;

View File

@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
import base from "../web/tailwind.config";
import surveyUi from "../../packages/survey-ui/tailwind.config";
export default {
...base,
@@ -9,4 +10,11 @@ export default {
"../web/modules/ui/**/*.{js,ts,jsx,tsx}",
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
...base.theme,
extend: {
...base.theme?.extend,
...surveyUi.theme?.extend,
},
},
};

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/microsoft-playwright-testing": "1.0.0-beta.7",

View File

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

View File

@@ -39,26 +39,32 @@
"react-dom": "^19.0.0"
},
"dependencies": {
"@formkit/auto-animate": "0.8.2",
"@radix-ui/react-checkbox": "1.3.1",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-dropdown-menu": "2.1.14",
"@radix-ui/react-label": "2.1.6",
"@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",
"tailwind-merge": "^2.5.5",
"lucide-react": "0.555.0"
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"date-fns": "^4.1.0",
"isomorphic-dompurify": "^2.33.0",
"react-day-picker": "9.6.7",
"tailwind-merge": "3.2.0",
"lucide-react": "0.507.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@storybook/react": "^8.5.4",
"@storybook/react-vite": "^8.5.4",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.0",
"@types/react": "19.2.1",
"@types/react-dom": "19.2.1",
"@vitejs/plugin-react": "^4.3.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"rimraf": "^6.0.1",
"tailwindcss": "^4.1.1",
"terser": "5.39.1",

View File

@@ -0,0 +1,186 @@
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>;
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 & Record<string, unknown>>()],
};
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: "أوافق على الشروط والأحكام",
onChange: () => {},
},
};
export const RTLWithConsent: Story = {
args: {
elementId: "consent-rtl-checked",
inputId: "consent-input-rtl-checked",
headline: "الشروط والأحكام",
checkboxLabel: "أوافق على الشروط والأحكام",
value: true,
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,98 @@
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 { useTextDirection } from "@/hooks/use-text-direction";
import { cn } from "@/lib/utils";
/**
* Props for the Consent element component
*/
export interface ConsentProps {
/** Unique identifier for the element container */
elementId: string;
/** The main element or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the consent checkbox */
inputId: string;
/** Label text for the consent checkbox */
checkboxLabel: string;
/** Whether consent is checked */
value?: boolean;
/** Callback function called when consent changes */
onChange: (checked: boolean) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the checkbox is disabled */
disabled?: boolean;
}
function Consent({
elementId,
headline,
description,
inputId,
checkboxLabel,
value = false,
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
}: ConsentProps): React.JSX.Element {
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", checkboxLabel],
});
const handleCheckboxChange = (checked: boolean): void => {
if (disabled) return;
onChange(checked);
};
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Consent Checkbox */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
<label
htmlFor={inputId}
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={detectedDir}>
<Checkbox
id={inputId}
checked={value}
onCheckedChange={handleCheckboxChange}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
aria-required={required}
/>
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
<span
className="text-input font-input-weight text-input-text flex-1 leading-none"
dir={detectedDir}>
{checkboxLabel}
</span>
</label>
</div>
</div>
);
}
export { Consent };

View File

@@ -0,0 +1,185 @@
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>;
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: "ابدأ الآن",
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,96 @@
import { LinkIcon } 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";
import { useTextDirection } from "@/hooks/use-text-direction";
/**
* Props for the CTA (Call to Action) element component
*/
export interface CTAProps {
/** Unique identifier for the element container */
elementId: string;
/** The main element or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the CTA button */
inputId: string;
/** Label text for the CTA button */
buttonLabel: string;
/** URL to open when button is clicked (if external button) */
buttonUrl?: string;
/** Whether the button opens an external URL */
buttonExternal?: boolean;
/** Callback function called when button is clicked */
onClick: () => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the button is disabled */
disabled?: boolean;
/** Variant for the button */
buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
}
function CTA({
elementId,
headline,
description,
inputId,
buttonLabel,
buttonUrl,
buttonExternal = false,
onClick,
required = false,
errorMessage,
dir = "auto",
disabled = false,
buttonVariant = "default",
}: CTAProps): React.JSX.Element {
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", buttonLabel],
});
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={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* CTA Button */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
<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}
{buttonExternal ? <LinkIcon className="size-4" /> : null}
</Button>
</div>
</div>
</div>
);
}
export { CTA };

View File

@@ -0,0 +1,313 @@
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: "يرجى اختيار تاريخ",
},
};
export const RTLWithValue: Story = {
args: {
headline: "ما هو تاريخ ميلادك؟",
description: "يرجى اختيار تاريخ",
value: "1990-01-15",
},
};
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,133 @@
import * as React from "react";
import { Calendar } from "@/components/general/calendar";
import { ElementHeader } from "@/components/general/element-header";
import { useTextDirection } from "@/hooks/use-text-direction";
import { getDateFnsLocale } from "@/lib/locale";
interface DateElementProps {
/** Unique identifier for the element container */
elementId: string;
/** The main element or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the date input */
inputId: string;
/** Current date value in ISO format (YYYY-MM-DD) */
value?: string;
/** Callback function called when the date value changes */
onChange: (value: string) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Minimum date allowed (ISO format: YYYY-MM-DD) */
minDate?: string;
/** Maximum date allowed (ISO format: YYYY-MM-DD) */
maxDate?: string;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the date input is disabled */
disabled?: boolean;
/** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */
locale?: string;
}
function DateElement({
elementId,
headline,
description,
inputId,
value,
onChange,
required = false,
minDate,
maxDate,
dir = "auto",
disabled = false,
locale = "en-US",
}: DateElementProps): React.JSX.Element {
const [date, setDate] = React.useState<Date | undefined>(value ? new Date(value) : undefined);
// 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)
const isoString = selectedDate.toISOString().split("T")[0];
onChange(isoString);
} else {
onChange("");
}
};
// Convert minDate/maxDate strings to Date objects
const minDateObj = minDate ? new Date(minDate) : undefined;
const maxDateObj = maxDate ? new Date(maxDate) : undefined;
// Create disabled function for date restrictions
const isDateDisabled = React.useCallback(
(dateToCheck: Date): boolean => {
if (disabled) return true;
if (minDateObj) {
const minAtMidnight = new Date(minDateObj.getFullYear(), minDateObj.getMonth(), minDateObj.getDate());
const checkAtMidnight = new Date(
dateToCheck.getFullYear(),
dateToCheck.getMonth(),
dateToCheck.getDate()
);
if (checkAtMidnight < minAtMidnight) return true;
}
if (maxDateObj) {
const maxAtMidnight = new Date(maxDateObj.getFullYear(), maxDateObj.getMonth(), maxDateObj.getDate());
const checkAtMidnight = new Date(
dateToCheck.getFullYear(),
dateToCheck.getMonth(),
dateToCheck.getDate()
);
if (checkAtMidnight > maxAtMidnight) return true;
}
return false;
},
[disabled, minDateObj, maxDateObj]
);
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? ""],
});
// Get locale for date formatting
const dateLocale = React.useMemo(() => {
return locale ? getDateFnsLocale(locale) : undefined;
}, [locale]);
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Calendar - Always visible */}
<div className="w-full">
<Calendar
mode="single"
selected={date}
captionLayout="dropdown"
disabled={isDateDisabled}
onSelect={handleDateSelect}
fromYear={minDateObj?.getFullYear() ?? 1900}
toYear={maxDateObj?.getFullYear() ?? 2100}
locale={dateLocale}
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input w-full border"
classNames={{
root: "w-full",
}}
/>
</div>
</div>
);
}
export { DateElement };
export type { DateElementProps };

View File

@@ -0,0 +1,242 @@
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: "يرجى اختيار ملف للتحميل",
},
};
export const RTLWithFiles: Story = {
args: {
headline: "قم بتحميل ملفاتك",
description: "الملفات التي قمت بتحميلها",
allowMultiple: true,
value: [
{
name: "ملف.pdf",
url: "data:application/pdf;base64,...",
size: 1024 * 500,
},
] as UploadedFile[],
},
};
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"]}
maxSizeInMB={5}
onChange={() => {}}
/>
</div>
),
};

View File

@@ -0,0 +1,326 @@
import { Upload, UploadIcon, X } from "lucide-react";
import * as React from "react";
import { ElementError } from "@/components/general/element-error";
import { ElementHeader } from "@/components/general/element-header";
import { Input } from "@/components/general/input";
import { useTextDirection } from "@/hooks/use-text-direction";
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;
/** Whether multiple files are allowed */
allowMultiple?: boolean;
/** Maximum file size in MB */
maxSizeInMB?: number;
/** 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;
/** 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;
}
function FileUpload({
elementId,
headline,
description,
inputId,
value = [],
onChange,
allowMultiple = false,
maxSizeInMB,
allowedFileExtensions,
required = false,
errorMessage,
dir = "auto",
disabled = false,
imageUrl,
videoUrl,
imageAltText,
placeholderText = "Click or drag to upload files",
}: FileUploadProps): React.JSX.Element {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = React.useState(false);
// Ensure value is always an array
const uploadedFiles = Array.isArray(value) ? value : [];
const validateFile = (file: File): string | null => {
// Check file extension
if (allowedFileExtensions && allowedFileExtensions.length > 0) {
const fileExtensionPart = file.name.split(".").pop()?.toLowerCase();
if (fileExtensionPart) {
const fileExtension = `.${fileExtensionPart}`;
if (!allowedFileExtensions.includes(fileExtension)) {
return `File type ${fileExtension} is not allowed. Allowed types: ${allowedFileExtensions.join(", ")}`;
}
}
}
// Check file size
if (maxSizeInMB) {
const fileSizeInMB = file.size / (1024 * 1024);
if (fileSizeInMB > maxSizeInMB) {
return `File size exceeds the maximum allowed size of ${String(maxSizeInMB)}MB`;
}
}
return null;
};
const processFiles = async (files: FileList | File[]): Promise<UploadedFile[]> => {
const fileArray = Array.from(files);
const processedFiles: UploadedFile[] = [];
for (const file of fileArray) {
const error = validateFile(file);
if (error) {
// In a real implementation, you might want to show this error
// eslint-disable-next-line no-console -- Error logging needed for file validation
console.error(error);
continue;
}
// Create a data URL for preview (in real implementation, upload to server)
const url = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(e.target?.result as string);
};
reader.readAsDataURL(file);
});
processedFiles.push({
name: file.name,
url,
size: file.size,
});
}
return processedFiles;
};
const handleFileSelection = async (files: FileList | File[]): Promise<void> => {
if (disabled) return;
const fileArray = Array.from(files);
// Validate file limits
if (!allowMultiple && fileArray.length > 1) {
// eslint-disable-next-line no-alert -- Alert needed for user feedback
alert("Only one file can be uploaded at a time");
return;
}
try {
setIsUploading(true);
const newFiles = await processFiles(fileArray);
if (allowMultiple) {
onChange([...uploadedFiles, ...newFiles]);
} else {
onChange(newFiles.slice(0, 1));
}
} catch (err) {
// eslint-disable-next-line no-console -- Error logging needed
console.error("Error uploading files:", err);
} finally {
setIsUploading(false);
// Reset input to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
if (!e.target.files || disabled) return;
await handleFileSelection(e.target.files);
};
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();
void handleFileSelection(e.dataTransfer.files);
};
const handleDeleteFile = (index: number, e: React.MouseEvent): void => {
e.stopPropagation();
const updatedFiles = [...uploadedFiles];
updatedFiles.splice(index, 1);
onChange(updatedFiles);
};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? ""],
});
// 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={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
imageUrl={imageUrl}
videoUrl={videoUrl}
imageAltText={imageAltText}
/>
{/* File Input */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
{/* Dashed border container */}
<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-input-bg hover:bg-input-hover-bg",
disabled && "cursor-not-allowed opacity-50"
)}>
{/* Uploaded files */}
{uploadedFiles.length > 0 ? (
<div className="flex w-full flex-col gap-2 p-2">
{uploadedFiles.map((file, index) => (
<div
key={index}
className={cn(
"border-input-border bg-input-bg text-input-text rounded-input relative m-1 rounded-md border"
)}>
{/* Delete button */}
<div className="absolute right-0 top-0 m-2">
<button
type="button"
onClick={(e) => {
handleDeleteFile(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>
{/* File icon and name */}
<div className="flex flex-col items-center justify-center p-2">
<UploadIcon />
<p
className="mt-1 w-full overflow-hidden overflow-ellipsis whitespace-nowrap px-2 text-center text-sm text-[var(--foreground)]"
title={file.name}>
{file.name}
</p>
</div>
</div>
))}
</div>
) : null}
{/* Upload area */}
<div className="w-full">
{isUploading ? (
<div className="flex animate-pulse items-center justify-center rounded-lg py-4">
<p className="text-muted-foreground text-sm font-medium">Uploading...</p>
</div>
) : null}
<label
htmlFor={inputId}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={cn("block w-full", disabled && "cursor-not-allowed", !showUploader && "hidden")}>
<button
type="button"
onClick={() => 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" />
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
<span className="text-input-text font-input-weight text-input m-2" id={`${inputId}-label`}>
{placeholderText}
</span>
<Input
ref={fileInputRef}
type="file"
id={inputId}
className="hidden"
multiple={allowMultiple}
accept={acceptAttribute}
onChange={(e) => {
void handleFileChange(e);
}}
disabled={disabled}
required={required}
dir={detectedDir}
aria-label="File upload"
aria-describedby={`${inputId}-label`}
/>
</button>
</label>
</div>
</div>
</div>
</div>
);
}
export { FileUpload };
export type { FileUploadProps };

View File

@@ -0,0 +1,360 @@
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: "سنستخدم هذا للاتصال بك",
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",
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,153 @@
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";
import { useTextDirection } from "@/hooks/use-text-direction";
/**
* 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;
}
function FormField({
elementId,
headline,
description,
fields,
value = {},
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
}: 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]);
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [
headline,
description ?? "",
...fields.map((field) => field.label).filter(Boolean),
...fields.map((field) => field.placeholder ?? "").filter(Boolean),
],
});
// 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={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} />
{/* Form Fields */}
<div className="relative space-y-3">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
{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}
placeholder={field.placeholder}
value={fieldValue}
onChange={(e) => {
handleFieldChange(field.id, e.target.value);
}}
required={fieldRequired}
disabled={disabled}
dir={!fieldValue ? detectedDir : "auto"}
aria-invalid={Boolean(errorMessage) || undefined}
/>
</div>
);
})}
</div>
</div>
);
}
export { FormField };
export type { FormFieldProps };

View File

@@ -0,0 +1,305 @@
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 & Record<string, unknown>>()],
};
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: "اختر قيمة لكل صف",
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",
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,173 @@
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 { useTextDirection } from "@/hooks/use-text-direction";
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;
}
function Matrix({
elementId,
headline,
description,
inputId,
rows,
columns,
value = {},
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
}: 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 });
}
};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [
headline,
description ?? "",
...rows.map((row) => row.label),
...columns.map((col) => col.label),
],
});
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Matrix Table */}
<div className="relative">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
{/* 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);
}}
disabled={disabled}
dir={detectedDir}
aria-invalid={Boolean(errorMessage)}>
<tr className={cn("relative", baseBgColor, rowHasError ? "bg-destructive-muted" : "")}>
{/* Row label */}
<td 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>
</td>
{/* 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} id={cellId} disabled={disabled} />
</Label>
</td>
);
})}
</tr>
</RadioGroupPrimitive.Root>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}
export { Matrix };
export type { MatrixProps };

View File

@@ -0,0 +1,350 @@
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>;
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 & Record<string, unknown>>()],
};
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: "ما هي الميزات التي تستخدمها؟",
description: "اختر كل ما ينطبق",
options: [
{ id: "opt-1", label: "الخيار الأول" },
{ id: "opt-2", label: "الخيار الثاني" },
{ id: "opt-3", label: "الخيار الثالث" },
{ id: "opt-4", label: "الخيار الرابع" },
],
},
};
export const RTLWithSelections: Story = {
args: {
headline: "ما هي اهتماماتك؟",
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,272 @@
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 { useTextDirection } from "@/hooks/use-text-direction";
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;
}
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?: "ltr" | "rtl" | "auto";
/** 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;
}
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,
}: 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 handleOptionChange = (optionId: string, checked: boolean): void => {
if (checked) {
onChange([...selectedValues, optionId]);
} else {
onChange(selectedValues.filter((id) => id !== optionId));
}
};
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 text-option-label";
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [
headline,
description ?? "",
...options.map((opt) => opt.label),
...(hasOtherOption ? [otherOptionLabel] : []),
],
});
// 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={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Options */}
<div className="relative space-y-3">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
{variant === "dropdown" ? (
<>
<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.map((option) => {
const isChecked = selectedValues.includes(option.id);
const optionId = `${inputId}-${option.id}`;
return (
<DropdownMenuCheckboxItem
key={option.id}
id={optionId}
checked={isChecked}
onCheckedChange={(checked) => {
handleOptionChange(option.id, checked);
}}
disabled={disabled}>
<span className={optionLabelClassName}>{option.label}</span>
</DropdownMenuCheckboxItem>
);
})}
{hasOtherOption && otherOptionId ? (
<DropdownMenuCheckboxItem
id={`${inputId}-${otherOptionId}`}
checked={isOtherSelected}
onCheckedChange={(checked) => {
handleOptionChange(otherOptionId, checked);
}}
disabled={disabled}>
<span className={optionLabelClassName}>{otherOptionLabel}</span>
</DropdownMenuCheckboxItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{isOtherSelected ? (
<Input
type="text"
value={otherValue}
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
dir={detectedDir}
className="w-full"
// eslint-disable-next-line jsx-a11y/no-autofocus -- Auto-focus is intentional for better UX when "other" option is selected
autoFocus
/>
) : null}
</>
) : (
<div className="space-y-2" role="group" aria-label={headline}>
{options.map((option) => {
const isChecked = selectedValues.includes(option.id);
const optionId = `${inputId}-${option.id}`;
return (
<label
key={option.id}
htmlFor={optionId}
className={cn(getOptionContainerClassName(isChecked), isChecked && "z-10")}>
<span className="flex items-center">
<Checkbox
id={optionId}
checked={isChecked}
onCheckedChange={(checked) => {
handleOptionChange(option.id, checked === true);
}}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
/>
<span className={cn("ml-3 mr-3", optionLabelClassName)}>{option.label}</span>
</span>
</label>
);
})}
{hasOtherOption && otherOptionId ? (
<div className="space-y-2">
<label
htmlFor={`${inputId}-${otherOptionId}`}
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
<span className="flex items-center">
<Checkbox
id={`${inputId}-${otherOptionId}`}
checked={isOtherSelected}
onCheckedChange={(checked) => {
handleOptionChange(otherOptionId, checked === true);
}}
disabled={disabled}
aria-invalid={Boolean(errorMessage)}
/>
<span className={cn("ml-3 mr-3 grow", optionLabelClassName)}>{otherOptionLabel}</span>
</span>
{isOtherSelected ? (
<Input
type="text"
value={otherValue}
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
dir={detectedDir}
className="mt-2 w-full"
// eslint-disable-next-line jsx-a11y/no-autofocus -- Auto-focus is intentional for better UX when "other" option is selected
autoFocus
/>
) : null}
</label>
</div>
) : null}
</div>
)}
</div>
</div>
);
}
export { MultiSelect };
export type { MultiSelectProps };

View File

@@ -0,0 +1,242 @@
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>;
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 & Record<string, unknown>>()],
};
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",
inputId: "nps-input-rtl",
headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟",
description: "يرجى التقييم من 0 إلى 10",
lowerLabel: "غير محتمل على الإطلاق",
upperLabel: "محتمل للغاية",
},
};
export const RTLWithSelection: Story = {
args: {
elementId: "nps-rtl-selection",
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,198 @@
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 { useTextDirection } from "@/hooks/use-text-direction";
import { cn } from "@/lib/utils";
interface NPSProps {
/** Unique identifier for the element container */
elementId: string;
/** The main element or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the NPS group */
inputId: string;
/** Currently selected NPS value (0 to 10) */
value?: number;
/** Callback function called when NPS value changes */
onChange: (value: number) => void;
/** Optional label for the lower end of the scale */
lowerLabel?: string;
/** Optional label for the upper end of the scale */
upperLabel?: string;
/** Whether color coding is enabled */
colorCoding?: boolean;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the controls are disabled */
disabled?: boolean;
}
function NPS({
elementId,
headline,
description,
inputId,
value,
onChange,
lowerLabel,
upperLabel,
colorCoding = false,
required = false,
errorMessage,
dir = "auto",
disabled = false,
}: 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;
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", lowerLabel ?? "", upperLabel ?? ""],
});
// 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 classes
let borderRadiusClasses = "";
if (isLast) {
borderRadiusClasses = detectedDir === "rtl" ? "rounded-l-input border-l" : "rounded-r-input border-r";
} else if (isFirst) {
borderRadiusClasses = detectedDir === "rtl" ? "rounded-r-input border-r" : "rounded-l-input border-l";
}
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 border-b border-l border-t transition-colors focus:border-2 focus:outline-none",
isSelected ? "bg-brand-20 border-brand z-10 border-2" : "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"
)}
onMouseEnter={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onMouseLeave={() => {
setHoveredValue(null);
}}
onFocus={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onBlur={() => {
setHoveredValue(null);
}}>
{colorCoding ? (
<div className={cn("absolute left-0 top-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={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* NPS Options */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
<fieldset className="w-full">
<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={detectedDir}>
{lowerLabel}
</Label>
) : null}
{upperLabel ? (
<Label
variant="default"
className="max-w-[50%] text-right text-xs leading-6"
dir={detectedDir}>
{upperLabel}
</Label>
) : null}
</div>
) : null}
</fieldset>
</div>
</div>
);
}
export { NPS };
export type { NPSProps };

View File

@@ -0,0 +1,276 @@
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: "اكتب إجابتك هنا...",
},
};
export const RTLLongAnswer: Story = {
args: {
headline: "أخبرنا عن تجربتك",
description: "يرجى تقديم أكبر قدر ممكن من التفاصيل",
placeholder: "اكتب ردك التفصيلي هنا...",
longAnswer: true,
rows: 5,
},
};
export const WithErrorAndRTL: Story = {
args: {
headline: "ما هو بريدك الإلكتروني؟",
inputType: "email",
placeholder: "email@example.com",
errorMessage: "يرجى إدخال عنوان بريد إلكتروني صالح",
required: true,
},
};
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,117 @@
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 { Textarea } from "@/components/general/textarea";
import { useTextDirection } from "@/hooks/use-text-direction";
import { cn } from "@/lib/utils";
interface OpenTextProps {
elementId: string;
headline: string;
description?: string;
placeholder?: string;
inputId: string;
value?: string;
onChange: (value: string) => void;
required?: boolean;
longAnswer?: boolean;
inputType?: "text" | "email" | "url" | "phone" | "number";
charLimit?: {
min?: number;
max?: number;
};
errorMessage?: string;
dir?: "ltr" | "rtl" | "auto";
rows?: number;
disabled?: boolean;
}
function OpenText({
elementId,
headline,
description,
placeholder,
value = "",
inputId,
onChange,
required = false,
longAnswer = false,
inputType = "text",
charLimit,
errorMessage,
dir = "auto",
rows = 3,
disabled = false,
}: OpenTextProps): React.JSX.Element {
const [currentLength, setCurrentLength] = React.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-neutral-400")}>
{currentLength}/{charLimit.max}
</span>
);
};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", placeholder ?? ""],
});
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Input or Textarea */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
<div className="space-y-1">
{longAnswer ? (
<Textarea
id={inputId}
placeholder={placeholder}
value={value}
onChange={handleChange}
required={required}
dir={detectedDir}
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={detectedDir}
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,278 @@
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 }>;
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: "ما هي الصورة التي تفضلها؟",
description: "اختر صورة واحدة",
options: defaultOptions.map((opt) => ({ ...opt, alt: "نص بديل" })),
},
};
export const RTLWithSelection: Story = {
args: {
headline: "اختر الصور التي تعجبك",
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,219 @@
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 { useTextDirection } from "@/hooks/use-text-direction";
import { cn } from "@/lib/utils";
/**
* Picture option for picture select element
*/
export interface PictureSelectOption {
/** Unique identifier for the option */
id: string;
/** URL of the image */
imageUrl: string;
/** Alt text for the image */
alt?: string;
}
interface PictureSelectProps {
/** Unique identifier for the element container */
elementId: string;
/** The main element or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the picture select group */
inputId: string;
/** Array of picture options to choose from */
options: PictureSelectOption[];
/** Currently selected option ID(s) - string for single select, string[] for multi select */
value?: string | string[];
/** Callback function called when selection changes */
onChange: (value: string | string[]) => void;
/** Whether multiple selections are allowed */
allowMulti?: boolean;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the options are disabled */
disabled?: boolean;
}
function PictureSelect({
elementId,
headline,
description,
inputId,
options,
value,
onChange,
allowMulti = false,
required = false,
errorMessage,
dir = "auto",
disabled = false,
}: 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 handleOptionChange = (optionId: string): void => {
if (disabled) return;
if (allowMulti) {
const currentArray = Array.isArray(value) ? value : [];
const newValue = currentArray.includes(optionId)
? currentArray.filter((id) => id !== optionId)
: [...currentArray, optionId];
onChange(newValue);
} else {
// Single select - toggle if same option, otherwise select new one
const newValue = selectedValues === optionId ? undefined : optionId;
onChange(newValue ?? "");
}
};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", ...options.map((opt) => opt.alt ?? "")],
});
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Picture Grid - 2 columns */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
{allowMulti ? (
<div className="grid grid-cols-2 gap-2">
{options.map((option) => {
const isSelected = (selectedValues as string[]).includes(option.id);
return (
<div
key={option.id}
className={cn(
"rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all",
disabled && "cursor-not-allowed opacity-50"
)}
onClick={() => {
handleOptionChange(option.id);
}}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !disabled) {
e.preventDefault();
handleOptionChange(option.id);
}
}}
aria-pressed={isSelected}
aria-disabled={disabled}>
{/* 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 right-[5%] top-[5%]">
<Checkbox
checked={isSelected}
onCheckedChange={() => {
handleOptionChange(option.id);
}}
disabled={disabled}
className="h-4 w-4"
aria-label={option.alt ?? `Select ${option.id}`}
/>
</div>
</div>
);
})}
</div>
) : (
<RadioGroupPrimitive.Root
value={selectedValues as string}
onValueChange={onChange}
disabled={disabled}
className="grid grid-cols-2 gap-2">
{options.map((option) => {
const optionId = `${inputId}-${option.id}`;
const isSelected = selectedValues === option.id;
return (
<div
key={option.id}
className={cn(
"rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all",
disabled && "cursor-not-allowed opacity-50"
)}
onClick={() => {
handleOptionChange(option.id);
}}
role="button"
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !disabled) {
e.preventDefault();
handleOptionChange(option.id);
}
}}
aria-pressed={isSelected}
aria-disabled={disabled}>
{/* 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 right-[5%] top-[5%]">
<RadioGroupItem
value={option.id}
id={optionId}
disabled={disabled}
className="h-4 w-4 bg-white"
aria-label={option.alt ?? `Select ${option.id}`}
/>
</div>
</div>
);
})}
</RadioGroupPrimitive.Root>
)}
</div>
</div>
);
}
export { PictureSelect };
export type { PictureSelectProps };

View File

@@ -0,0 +1,220 @@
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>;
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",
headline: "رتب هذه العناصر حسب الأهمية",
description: "انقر على العناصر لإضافتها إلى الترتيب، ثم استخدم الأسهم لإعادة الترتيب",
options: [
{ id: "1", label: "الخيار الأول" },
{ id: "2", label: "الخيار الثاني" },
{ id: "3", label: "الخيار الثالث" },
{ id: "4", label: "الخيار الرابع" },
],
},
};
export const RTLWithRanking: Story = {
args: {
elementId: "ranking-rtl-ranked",
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,238 @@
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 { useTextDirection } from "@/hooks/use-text-direction";
import { cn } from "@/lib/utils";
/**
* 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?: "ltr" | "rtl" | "auto";
/** Whether the controls are disabled */
disabled?: boolean;
}
function Ranking({
elementId,
headline,
description,
inputId,
options,
value = [],
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
}: RankingProps): React.JSX.Element {
// Ensure value is always an array
const rankedIds = React.useMemo(() => (Array.isArray(value) ? value : []), [value]);
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", ...options.map((opt) => opt.label)],
});
// 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.findIndex((id) => id === 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={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Ranking Options */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
<fieldset className="w-full" dir={detectedDir}>
<legend className="sr-only">Ranking options</legend>
<div className="space-y-2" ref={parent as React.Ref<HTMLDivElement>}>
{allItems.map((item) => {
const isRanked = rankedIds.includes(item.id);
const rankIndex = rankedIds.findIndex((id) => id === 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 = detectedDir === "rtl" ? "pr-3" : "pl-3";
// RTL-aware border class for control buttons
const borderClass = detectedDir === "rtl" ? "border-r" : "border-l";
// RTL-aware border radius classes for control buttons
let topButtonRadiusClass = "rounded-tr-md";
if (isFirst) {
topButtonRadiusClass = "cursor-not-allowed opacity-30";
} else if (detectedDir === "rtl") {
topButtonRadiusClass = "rounded-tl-md";
}
let bottomButtonRadiusClass = "rounded-br-md";
if (isLast) {
bottomButtonRadiusClass = "cursor-not-allowed opacity-30";
} else if (detectedDir === "rtl") {
bottomButtonRadiusClass = "rounded-bl-md";
}
return (
<div
key={item.id}
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={() => {
handleItemClick(item);
}}
disabled={disabled}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleItemClick(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(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold",
isRanked
? "bg-brand border-brand text-white"
: "border-option-border 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={detectedDir}>
{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();
handleMove(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();
handleMove(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>
);
})}
</div>
</fieldset>
</div>
</div>
);
}
export { Ranking };
export type { RankingProps };

View File

@@ -0,0 +1,318 @@
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>;
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 & Record<string, unknown>>()],
};
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",
inputId: "rating-input-rtl",
headline: "كيف تقيم تجربتك؟",
description: "يرجى تقييم تجربتك من 1 إلى 5",
scale: "number",
range: 5,
lowerLabel: "غير راض",
upperLabel: "راض جداً",
},
};
export const RTLWithSelection: Story = {
args: {
elementId: "rating-rtl-selection",
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,444 @@
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 { useTextDirection } from "@/hooks/use-text-direction";
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;
}
function Rating({
elementId,
headline,
description,
inputId,
scale,
range,
value,
onChange,
lowerLabel,
upperLabel,
colorCoding = false,
required = false,
errorMessage,
dir = "auto",
disabled = false,
}: 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;
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", lowerLabel ?? "", upperLabel ?? ""],
});
// 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 classes
let borderRadiusClasses = "";
if (isLast) {
borderRadiusClasses = detectedDir === "rtl" ? "rounded-l-input border-l" : "rounded-r-input border-r";
} else if (isFirst) {
borderRadiusClasses = detectedDir === "rtl" ? "rounded-r-input border-r" : "rounded-l-input border-l";
}
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 border-b border-l border-t transition-colors focus:border-2 focus:outline-none",
isSelected ? "bg-brand-20 border-brand z-10 border-2" : "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"
)}
onMouseEnter={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onMouseLeave={() => {
setHoveredValue(null);
}}
onFocus={() => {
if (!disabled) {
setHoveredValue(number);
}
}}
onBlur={() => {
setHoveredValue(null);
}}>
{colorCoding ? (
<div
className={cn("absolute left-0 top-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="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 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="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={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Rating Options */}
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={detectedDir} />
<fieldset className="w-full">
<legend className="sr-only">Rating options</legend>
<div className="flex w-full">
{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={detectedDir}>
{lowerLabel}
</Label>
) : null}
{upperLabel ? (
<Label
variant="default"
className="max-w-[50%] text-right text-xs leading-6"
dir={detectedDir}>
{upperLabel}
</Label>
) : null}
</div>
) : null}
</fieldset>
</div>
</div>
);
}
export { Rating };
export type { RatingProps };

View File

@@ -0,0 +1,381 @@
import type { Meta, StoryObj } from "@storybook/react";
import * as React from "react";
import { useEffect, useState } from "react";
import {
type BaseStylingOptions,
type InputLayoutStylingOptions,
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 & InputLayoutStylingOptions> &
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: "ما هو خيارك المفضل؟",
description: "اختر خيارًا واحدًا",
options: [
{ id: "opt-1", label: "الخيار الأول" },
{ id: "opt-2", label: "الخيار الثاني" },
{ id: "opt-3", label: "الخيار الثالث" },
{ id: "opt-4", label: "الخيار الرابع" },
],
},
};
export const RTLWithSelection: Story = {
args: {
headline: "ما هو تفضيلك؟",
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,249 @@
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 { useTextDirection } from "@/hooks/use-text-direction";
import { cn } from "@/lib/utils";
/**
* Option for single-select element
*/
export interface SingleSelectOption {
/** Unique identifier for the option */
id: string;
/** Display label for the option */
label: string;
}
interface SingleSelectProps {
/** Unique identifier for the element container */
elementId: string;
/** The main element or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the single-select group */
inputId: string;
/** Array of options to choose from */
options: SingleSelectOption[];
/** Currently selected option ID */
value?: string;
/** Callback function called when selection changes */
onChange: (value: string) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display below the options */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the options are disabled */
disabled?: boolean;
/** Display variant: 'list' shows radio buttons, 'dropdown' shows a dropdown menu */
variant?: "list" | "dropdown";
/** Placeholder text for dropdown button when no option is selected */
placeholder?: string;
/** ID for the 'other' option that allows custom input */
otherOptionId?: string;
/** Label for the 'other' option */
otherOptionLabel?: string;
/** Placeholder text for the 'other' input field */
otherOptionPlaceholder?: string;
/** Custom value entered in the 'other' input field */
otherValue?: string;
/** Callback when the 'other' input value changes */
onOtherValueChange?: (value: string) => void;
}
function SingleSelect({
elementId,
headline,
description,
inputId,
options,
value,
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
variant = "list",
placeholder = "Select an option...",
otherOptionId,
otherOptionLabel = "Other",
otherOptionPlaceholder = "Please specify",
otherValue = "",
onOtherValueChange,
}: 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 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 text-option font-option-weight text-option-label";
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [
headline,
description ?? "",
...options.map((opt) => opt.label),
...(hasOtherOption ? [otherOptionLabel] : []),
],
});
// 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={detectedDir}>
{/* Headline */}
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
{/* Options */}
<div className="space-y-3">
{variant === "dropdown" ? (
<>
<ElementError errorMessage={errorMessage} dir={detectedDir} />
<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.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}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{isOtherSelected ? (
<Input
type="text"
value={otherValue}
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
dir={detectedDir}
className="w-full"
// eslint-disable-next-line jsx-a11y/no-autofocus -- Auto-focus is intentional for better UX when "other" option is selected
autoFocus
/>
) : null}
</>
) : (
<RadioGroup
value={selectedValue}
onValueChange={onChange}
disabled={disabled}
errorMessage={errorMessage}
dir={detectedDir}
className="w-full gap-0 space-y-2">
{options.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 text-sm">
<RadioGroupItem value={option.id} id={optionId} disabled={disabled} />
<span className={cn("ml-3 mr-3 grow", optionLabelClassName)}>{option.label}</span>
</span>
</label>
);
})}
{hasOtherOption && otherOptionId ? (
<label
htmlFor={`${inputId}-${otherOptionId}`}
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
<span className="flex items-center text-sm">
<RadioGroupItem
value={otherOptionId}
id={`${inputId}-${otherOptionId}`}
disabled={disabled}
/>
<span className={cn("ml-3 mr-3 grow", optionLabelClassName)}>{otherOptionLabel}</span>
</span>
{isOtherSelected ? (
<Input
type="text"
value={otherValue}
onChange={handleOtherInputChange}
placeholder={otherOptionPlaceholder}
disabled={disabled}
dir={detectedDir}
className="mt-2 w-full"
// eslint-disable-next-line jsx-a11y/no-autofocus -- Auto-focus is intentional for better UX when "other" option is selected
autoFocus
/>
) : null}
</label>
) : null}
</RadioGroup>
)}
</div>
</div>
);
}
export { SingleSelect };
export type { SingleSelectProps };

View File

@@ -3,7 +3,7 @@ import { TriangleAlertIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "./alert";
const meta: Meta<typeof Alert> = {
title: "UI-package/Alert",
title: "UI-package/General/Alert",
component: Alert,
tags: ["autodocs"],
parameters: {

View File

@@ -1,6 +1,6 @@
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "../lib/utils";
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",

View File

@@ -7,6 +7,8 @@ interface StylingOptions {
buttonHeight: string;
buttonWidth: string;
buttonFontSize: string;
buttonFontFamily: string;
buttonFontWeight: string;
buttonBorderRadius: string;
buttonBgColor: string;
buttonTextColor: string;
@@ -18,7 +20,7 @@ type ButtonProps = React.ComponentProps<typeof Button>;
type StoryProps = ButtonProps & StylingOptions;
const meta: Meta<StoryProps> = {
title: "UI-package/Button",
title: "UI-package/General/Button",
component: Button,
tags: ["autodocs"],
parameters: {
@@ -27,7 +29,7 @@ const meta: Meta<StoryProps> = {
argTypes: {
variant: {
control: "select",
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"],
description: "Visual style variant of the button",
table: { category: "Component Props" },
},
@@ -61,6 +63,8 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
buttonHeight,
buttonWidth,
buttonFontSize,
buttonFontFamily,
buttonFontWeight,
buttonBorderRadius,
buttonBgColor,
buttonTextColor,
@@ -68,16 +72,18 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
buttonPaddingY,
} = args;
const cssVarStyle = {
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,
} as React.CSSProperties;
};
return (
<div style={cssVarStyle}>
@@ -88,16 +94,8 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
export const StylingPlayground: Story = {
args: {
variant: "custom",
children: "Custom Button",
// Default styling values
buttonHeight: "40px",
buttonWidth: "auto",
buttonFontSize: "14px",
buttonBorderRadius: "0.5rem",
buttonBgColor: "#3b82f6",
buttonTextColor: "#ffffff",
buttonPaddingX: "16px",
buttonPaddingY: "8px",
},
argTypes: {
// Button Styling (CSS Variables) - Only for this story
@@ -105,56 +103,60 @@ export const StylingPlayground: Story = {
control: "text",
table: {
category: "Button Styling",
defaultValue: { summary: "40px" },
},
},
buttonWidth: {
control: "text",
table: {
category: "Button Styling",
defaultValue: { summary: "auto" },
},
},
buttonFontSize: {
control: "text",
table: {
category: "Button Styling",
defaultValue: { summary: "14px" },
},
},
buttonFontFamily: {
control: "text",
table: {
category: "Button Styling",
},
},
buttonFontWeight: {
control: "text",
table: {
category: "Button Styling",
},
},
buttonBorderRadius: {
control: "text",
table: {
category: "Button Styling",
defaultValue: { summary: "var(--fb-border-radius)" },
},
},
buttonBgColor: {
control: "color",
table: {
category: "Button Styling",
defaultValue: { summary: "var(--fb-brand-color)" },
},
},
buttonTextColor: {
control: "color",
table: {
category: "Button Styling",
defaultValue: { summary: "var(--fb-brand-text-color)" },
},
},
buttonPaddingX: {
control: "text",
table: {
category: "Button Styling",
defaultValue: { summary: "16px" },
},
},
buttonPaddingY: {
control: "text",
table: {
category: "Button Styling",
defaultValue: { summary: "8px" },
},
},
},
@@ -223,6 +225,13 @@ export const Icon: Story = {
},
};
export const Custom: Story = {
args: {
variant: "custom",
children: "Custom Button",
},
};
export const Disabled: Story = {
args: {
disabled: true,

View File

@@ -1,7 +1,7 @@
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";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -16,6 +16,7 @@ const buttonVariants = cva(
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",
@@ -31,20 +32,6 @@ const buttonVariants = cva(
}
);
// Default styles driven by CSS variables
export const cssVarStyles: React.CSSProperties = {
height: "var(--fb-button-height)",
width: "var(--fb-button-width)",
fontSize: "var(--fb-button-font-size)",
borderRadius: "var(--fb-button-border-radius)",
backgroundColor: "var(--fb-button-bg-color)",
color: "var(--fb-button-text-color)",
paddingLeft: "var(--fb-button-padding-x)",
paddingRight: "var(--fb-button-padding-x)",
paddingTop: "var(--fb-button-padding-y)",
paddingBottom: "var(--fb-button-padding-y)",
};
function Button({
className,
variant,
@@ -62,7 +49,6 @@ function Button({
data-slot="button"
aria-label={props["aria-label"]}
className={cn(buttonVariants({ variant, size }), className)}
style={cssVarStyles}
{...props}
/>
);

View File

@@ -0,0 +1,214 @@
"use client";
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
}: {
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
}: {
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 }: { 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,
...props
}: 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]);
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}
formatters={{
formatMonthDropdown: (date) => {
if (resolvedLocale) {
return format(date, "MMM", { locale: resolvedLocale });
}
return date.toLocaleString("default", { month: "short" });
},
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-[--cell-size] w-full px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-[--cell-size] gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input-border shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none text-[var(--fb-input-color)] opacity-70",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn("select-none w-[--cell-size]", defaultClassNames.week_number_header),
week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
defaultClassNames.today
),
outside: cn(
"text-[var(--fb-input-color)] opacity-70 aria-selected:text-[var(--fb-input-color)] opacity-70",
defaultClassNames.outside
),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
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 font-normal leading-none hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -3,7 +3,7 @@ import { Checkbox } from "./checkbox";
import { Label } from "./label";
const meta: Meta<typeof Checkbox> = {
title: "UI-package/Checkbox",
title: "UI-package/General/Checkbox",
component: Checkbox,
parameters: {
layout: "centered",

View File

@@ -3,7 +3,7 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import * as React from "react";
import { cn } from "../lib/utils";
import { cn } from "@/lib/utils";
function Checkbox({
className,
@@ -13,7 +13,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer size-4 shrink-0 rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input-border dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs text-input-text bg-input-bg peer size-4 shrink-0 rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>

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 @@
"use client";
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 }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: 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
}: 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-y-auto overflow-x-hidden 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 select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
);
}
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 select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<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 select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<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 }: 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 select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto 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,
};

View File

@@ -0,0 +1,36 @@
import { AlertCircle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
interface ElementErrorProps {
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
}
function ElementError({ errorMessage, dir = "auto" }: ElementErrorProps): React.JSX.Element | null {
if (!errorMessage) {
return null;
}
return (
<>
{/* Error indicator bar */}
<div
className={cn(
"bg-destructive absolute bottom-0 top-0 w-[4px]",
dir === "rtl" ? "right-[-12px]" : "left-[-12px]"
)}
/>
{/* Error message - shown at top */}
<div className="text-destructive flex items-center gap-1 text-sm" dir={dir}>
<AlertCircle className="size-4" />
<span>{errorMessage}</span>
</div>
</>
);
}
export { ElementError };
export type { ElementErrorProps };

View File

@@ -0,0 +1,111 @@
import { type Meta, type StoryObj } from "@storybook/react";
import { ElementHeader } from "./element-header";
const meta: Meta<typeof ElementHeader> = {
title: "UI-package/General/ElementHeader",
component: ElementHeader,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
argTypes: {
headline: {
control: "text",
description: "The main headline text",
},
description: {
control: "text",
description: "Optional description text displayed below the headline",
},
required: {
control: "boolean",
description: "Whether the field is required (shows asterisk)",
},
htmlFor: {
control: "text",
description: "The id of the form control this header is associated with",
},
imageUrl: {
control: "text",
description: "URL of an image to display above the headline",
},
videoUrl: {
control: "text",
description: "URL of a video (YouTube, Vimeo, or Loom) to display above the headline",
},
imageAltText: {
control: "text",
description: "Alt text for the image",
},
},
args: {
headline: "Element Title",
},
};
export default meta;
type Story = StoryObj<typeof ElementHeader>;
export const Default: Story = {
args: {
headline: "What is your name?",
},
};
export const WithDescription: Story = {
args: {
headline: "How satisfied are you?",
description: "Please rate your experience from 1 to 10",
},
};
export const Required: Story = {
args: {
headline: "Email Address",
required: true,
},
};
export const RequiredWithDescription: Story = {
args: {
headline: "Phone Number",
description: "We'll use this to contact you about your order",
required: true,
},
};
export const WithHtmlFor: Story = {
render: () => (
<div className="space-y-4">
<ElementHeader headline="Username" description="Choose a unique username" htmlFor="username" />
<input id="username" type="text" placeholder="Enter username" className="rounded border px-3 py-2" />
</div>
),
};
export const WithImage: Story = {
args: {
headline: "What do you see in this image?",
description: "Please describe what you observe",
imageUrl: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop",
imageAltText: "Mountain landscape",
},
};
export const WithVideo: Story = {
args: {
headline: "Watch this video",
description: "Please watch the video and answer the questions below",
videoUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
},
};
export const WithImageAndDescription: Story = {
args: {
headline: "Rate this design",
description: "On a scale of 1-10, how would you rate this design?",
required: true,
imageUrl: "https://images.unsplash.com/photo-1561070791-2526d30994b5?w=800&h=600&fit=crop",
imageAltText: "Design mockup",
},
};

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import { ElementMedia } from "@/components/general/element-media";
import { Label } from "@/components/general/label";
import { cn } from "@/lib/utils";
interface ElementHeaderProps extends React.ComponentProps<"div"> {
headline: string;
description?: string;
required?: boolean;
htmlFor?: string;
imageUrl?: string;
videoUrl?: string;
imageAltText?: string;
}
function ElementHeader({
headline,
description,
required = false,
htmlFor,
className,
imageUrl,
videoUrl,
imageAltText,
...props
}: ElementHeaderProps): React.JSX.Element {
const isMediaAvailable = imageUrl ?? videoUrl;
return (
<div className={cn("space-y-2", className)} {...props}>
{/* Media (Image or Video) */}
{isMediaAvailable ? (
<ElementMedia imgUrl={imageUrl} videoUrl={videoUrl} altText={imageAltText} />
) : null}
{/* Headline */}
<div className="flex">
<Label htmlFor={htmlFor} variant="headline">
{headline}
</Label>
{required ? <span>*</span> : null}
</div>
{/* Description/Subheader */}
{description ? (
<Label htmlFor={htmlFor} variant="description">
{description}
</Label>
) : null}
</div>
);
}
export { ElementHeader };
export type { ElementHeaderProps };

View File

@@ -0,0 +1,100 @@
"use client";
import { Download, Expand } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
// Function to add extra params to videoUrls in order to reduce video controls
const getVideoUrlWithParams = (videoUrl: string): string | undefined => {
// First convert to embed URL
const embedUrl = convertToEmbedUrl(videoUrl);
if (!embedUrl) return undefined;
const isYoutubeVideo = checkForYoutubeUrl(videoUrl);
const isVimeoUrl = checkForVimeoUrl(videoUrl);
const isLoomUrl = checkForLoomUrl(videoUrl);
if (isYoutubeVideo) {
// For YouTube, add parameters to embed URL
const separator = embedUrl.includes("?") ? "&" : "?";
return `${embedUrl}${separator}controls=0`;
} else if (isVimeoUrl) {
// For Vimeo, add parameters to embed URL
const separator = embedUrl.includes("?") ? "&" : "?";
return `${embedUrl}${separator}title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false`;
} else if (isLoomUrl) {
// For Loom, add parameters to embed URL
const separator = embedUrl.includes("?") ? "&" : "?";
return `${embedUrl}${separator}hide_share=true&hideEmbedTopBar=true&hide_title=true`;
}
return embedUrl;
};
interface ElementMediaProps {
imgUrl?: string;
videoUrl?: string;
altText?: string;
}
function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: ElementMediaProps): React.JSX.Element {
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
const [isLoading, setIsLoading] = React.useState(true);
if (!imgUrl && !videoUrl) {
return <></>;
}
return (
<div className="group/image relative mb-6 block min-h-40 rounded-md">
{isLoading ? (
<div className="absolute inset-auto flex h-full w-full animate-pulse items-center justify-center rounded-md bg-slate-200" />
) : null}
{imgUrl ? (
<img
key={imgUrl}
src={imgUrl}
alt={altText}
className={cn("mx-auto max-h-[40dvh] rounded-md object-contain", isLoading ? "opacity-0" : "")}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setIsLoading(false);
}}
/>
) : null}
{videoUrlWithParams ? (
<div className="relative">
<div className="rounded-md bg-black">
<iframe
src={videoUrlWithParams}
title="Question video"
frameBorder="0"
className={cn("aspect-video w-full rounded-md", isLoading ? "opacity-0" : "")}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setIsLoading(false);
}}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
/>
</div>
</div>
) : null}
<a
href={imgUrl ? imgUrl : convertToEmbedUrl(videoUrl ?? "")}
target="_blank"
rel="noreferrer"
aria-label="Open in new tab"
className="absolute bottom-2 right-2 flex items-center gap-2 rounded-md bg-slate-800 bg-opacity-40 p-1.5 text-white opacity-0 backdrop-blur-lg transition duration-300 ease-in-out hover:bg-opacity-65 group-hover/image:opacity-100">
{imgUrl ? <Download size={20} /> : <Expand size={20} />}
</a>
</div>
);
}
export { ElementMedia };
export type { ElementMediaProps };

View File

@@ -17,12 +17,13 @@ interface StylingOptions {
inputPaddingX: string;
inputPaddingY: string;
inputShadow: string;
brandColor: string;
}
type StoryProps = InputProps & Partial<StylingOptions>;
const meta: Meta<StoryProps> = {
title: "UI-package/Input",
title: "UI-package/General/Input",
component: Input,
parameters: {
layout: "centered",
@@ -75,6 +76,7 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
inputPaddingX,
inputPaddingY,
inputShadow,
brandColor,
} = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
@@ -91,6 +93,7 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
"--fb-input-padding-x": inputPaddingX,
"--fb-input-padding-y": inputPaddingY,
"--fb-input-shadow": inputShadow,
"--fb-survey-brand-color": brandColor,
};
return (
@@ -103,20 +106,6 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
export const StylingPlayground: Story = {
args: {
placeholder: "Enter text...",
// Default styling values
inputWidth: "400px",
inputHeight: "2.5rem",
inputBgColor: "#ffffff",
inputBorderColor: "#e2e8f0",
inputBorderRadius: "0.5rem",
inputFontFamily: "system-ui, sans-serif",
inputFontSize: "0.875rem",
inputFontWeight: "400",
inputColor: "#1e293b",
inputPlaceholderColor: "#94a3b8",
inputPaddingX: "0.75rem",
inputPaddingY: "0.5rem",
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
},
argTypes: {
// Input Styling (CSS Variables) - Only for this story
@@ -211,6 +200,13 @@ export const StylingPlayground: Story = {
defaultValue: { summary: "0 1px 2px 0 rgb(0 0 0 / 0.05)" },
},
},
brandColor: {
control: "color",
table: {
category: "Input Styling",
defaultValue: { summary: "var(--fb-survey-brand-color)" },
},
},
},
decorators: [withCSSVariables],
};
@@ -271,34 +267,6 @@ export const DisabledWithValue: Story = {
},
};
export const FileUpload: Story = {
args: {
type: "file",
},
};
export const FileUploadWithRTL: Story = {
args: {
type: "file",
dir: "rtl",
},
};
export const FileUploadWithError: Story = {
args: {
type: "file",
errorMessage: "Please upload a valid file",
},
};
export const FileUploadWithErrorAndRTL: Story = {
args: {
type: "file",
errorMessage: "Please upload a valid file",
dir: "rtl",
},
};
export const RTL: Story = {
args: {
dir: "rtl",

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { ElementError } from "@/components/general/element-error";
import { cn } from "@/lib/utils";
interface InputProps extends React.ComponentProps<"input"> {
/** Text direction for RTL language support */
dir?: "ltr" | "rtl" | "auto";
/** Error message to display above the input */
errorMessage?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, errorMessage, dir, ...props }, ref): React.JSX.Element => {
const hasError = Boolean(errorMessage);
return (
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
<input
ref={ref}
type={type}
dir={dir}
data-slot="input"
aria-invalid={hasError || undefined}
className={cn(
// Layout and behavior
"flex min-w-0 border outline-none transition-[color,box-shadow]",
// Customizable styles via CSS variables (using Tailwind theme extensions)
"w-input h-input",
"bg-input-bg border-input-border rounded-input",
"font-input font-input-weight",
"text-input",
"px-input-x py-input-y",
"shadow-input",
// Placeholder styling
"[&::placeholder]:opacity-input-placeholder",
"placeholder:text-input-placeholder",
// Selection styling
"selection:bg-primary selection:text-primary-foreground",
// File input specifics
"file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
// Focus ring
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
// Error state ring
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
// Disabled state
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
);
Input.displayName = "Input";
export { Input };
export type { InputProps };

View File

@@ -34,7 +34,7 @@ type StoryProps = LabelProps &
Partial<HeadlineStylingOptions & DescriptionStylingOptions & DefaultStylingOptions>;
const meta: Meta<StoryProps> = {
title: "UI-package/Label",
title: "UI-package/General/Label",
component: Label,
parameters: {
layout: "centered",
@@ -78,11 +78,11 @@ const withHeadlineCSSVariables: Decorator<StoryProps> = (Story, context) => {
const { headlineFontFamily, headlineFontWeight, headlineFontSize, headlineColor, headlineOpacity } = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
"--fb-question-headline-font-family": headlineFontFamily ?? undefined,
"--fb-question-headline-font-weight": headlineFontWeight ?? undefined,
"--fb-question-headline-font-size": headlineFontSize ?? undefined,
"--fb-question-headline-color": headlineColor ?? undefined,
"--fb-question-headline-opacity": headlineOpacity ?? undefined,
"--fb-element-headline-font-family": headlineFontFamily ?? undefined,
"--fb-element-headline-font-weight": headlineFontWeight ?? undefined,
"--fb-element-headline-font-size": headlineFontSize ?? undefined,
"--fb-element-headline-color": headlineColor ?? undefined,
"--fb-element-headline-opacity": headlineOpacity ?? undefined,
};
return (
@@ -105,11 +105,11 @@ const withDescriptionCSSVariables: Decorator<StoryProps> = (Story, context) => {
} = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
"--fb-question-description-font-family": descriptionFontFamily ?? undefined,
"--fb-question-description-font-weight": descriptionFontWeight ?? undefined,
"--fb-question-description-font-size": descriptionFontSize ?? undefined,
"--fb-question-description-color": descriptionColor ?? undefined,
"--fb-question-description-opacity": descriptionOpacity ?? undefined,
"--fb-element-description-font-family": descriptionFontFamily ?? undefined,
"--fb-element-description-font-weight": descriptionFontWeight ?? undefined,
"--fb-element-description-font-size": descriptionFontSize ?? undefined,
"--fb-element-description-color": descriptionColor ?? undefined,
"--fb-element-description-opacity": descriptionOpacity ?? undefined,
};
return (

View File

@@ -0,0 +1,94 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import DOMPurify from "isomorphic-dompurify";
import * as React from "react";
import { cn } from "@/lib/utils";
interface LabelProps extends React.ComponentProps<typeof LabelPrimitive.Root> {
/** Label variant for different styling contexts */
variant?: "default" | "headline" | "description";
}
/**
* Checks if a string contains valid HTML markup
* @param str - The input string to test
* @returns true if the string contains valid HTML elements, false otherwise
*/
const isValidHTML = (str: string): boolean => {
if (!str) return false;
try {
const doc = new DOMParser().parseFromString(str, "text/html");
const errorNode = doc.querySelector("parsererror");
if (errorNode) return false;
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
} catch {
return false;
}
};
/**
* Strips inline style attributes to prevent CSP violations
* @param html - The HTML string to clean
* @returns HTML string without inline style attributes
*/
const stripInlineStyles = (html: string): string => {
return html.replace(/\s*style\s*=\s*["'][^"']*["']/gi, "");
};
function Label({ className, variant = "default", children, ...props }: LabelProps): React.JSX.Element {
// Check if children is a string and contains HTML
const childrenString = typeof children === "string" ? children : null;
const strippedContent = childrenString ? stripInlineStyles(childrenString) : "";
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
const safeHtml =
isHtml && strippedContent
? DOMPurify.sanitize(strippedContent, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"],
})
: "";
// Determine variant class
let variantClass = "label-default";
if (variant === "headline") {
variantClass = "label-headline";
} else if (variant === "description") {
variantClass = "label-description";
}
// If HTML, render with dangerouslySetInnerHTML, otherwise render normally
if (isHtml && safeHtml) {
return (
<LabelPrimitive.Root
data-slot="label"
data-variant={variant}
className={cn(
"flex select-none items-center gap-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
variantClass,
className
)}
{...props}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
);
}
return (
<LabelPrimitive.Root
data-slot="label"
data-variant={variant}
className={cn(
"flex select-none items-center gap-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
variantClass,
className
)}
{...props}>
{children}
</LabelPrimitive.Root>
);
}
export { Label };
export type { LabelProps };

View File

@@ -0,0 +1,41 @@
"use client";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@/lib/utils";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
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 origin-(--radix-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,152 @@
import type { Decorator, Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Progress, type ProgressProps } from "./progress";
// Styling options for the StylingPlayground story
interface StylingOptions {
trackHeight: string;
trackBgColor: string;
trackBorderRadius: string;
indicatorBgColor: string;
indicatorBorderRadius: string;
}
type StoryProps = ProgressProps & Partial<StylingOptions>;
const meta: Meta<StoryProps> = {
title: "UI-package/General/Progress",
component: Progress,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
argTypes: {
value: {
control: { type: "range", min: 0, max: 100, step: 1 },
description: "Progress value (0-100)",
table: { category: "Component Props" },
},
},
};
export default meta;
type Story = StoryObj<StoryProps>;
// Decorator to apply CSS variables from story args
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
const args = context.args as StoryProps;
const { trackHeight, trackBgColor, trackBorderRadius, indicatorBgColor, indicatorBorderRadius } = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
"--fb-progress-track-height": trackHeight,
"--fb-progress-track-bg-color": trackBgColor,
"--fb-progress-track-border-radius": trackBorderRadius,
"--fb-progress-indicator-bg-color": indicatorBgColor,
"--fb-progress-indicator-border-radius": indicatorBorderRadius,
};
return (
<div style={cssVarStyle}>
<Story />
</div>
);
};
export const Default: Story = {
render: (args: StoryProps) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 50,
},
};
export const Zero: Story = {
render: (args: StoryProps) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 0,
},
};
export const Half: Story = {
render: (args: StoryProps) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 50,
},
};
export const Complete: Story = {
render: (args: StoryProps) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 100,
},
};
export const CustomStyles: Story = {
render: (args: StoryProps) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 75,
trackHeight: "1.25rem",
trackBgColor: "hsl(0 0% 0% / 0.3)",
trackBorderRadius: "0.75rem",
indicatorBgColor: "hsl(142 76% 36%)",
indicatorBorderRadius: "0.75rem",
},
argTypes: {
trackHeight: {
control: "text",
table: {
category: "Progress Styling",
defaultValue: { summary: "0.5rem" },
},
},
trackBgColor: {
control: "color",
table: {
category: "Progress Styling",
defaultValue: { summary: "hsl(222.2 47.4% 11.2% / 0.2)" },
},
},
trackBorderRadius: {
control: "text",
table: {
category: "Progress Styling",
defaultValue: { summary: "var(--radius)" },
},
},
indicatorBgColor: {
control: "color",
table: {
category: "Progress Styling",
defaultValue: { summary: "hsl(222.2 47.4% 11.2%)" },
},
},
indicatorBorderRadius: {
control: "text",
table: {
category: "Progress Styling",
defaultValue: { summary: "var(--radius)" },
},
},
},
decorators: [withCSSVariables],
};

View File

@@ -0,0 +1,30 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as React from "react";
import { cn } from "@/lib/utils";
interface ProgressProps extends Omit<React.ComponentProps<"div">, "children"> {
value?: number;
}
function Progress({ className, value, ...props }: ProgressProps): React.JSX.Element {
const progressValue: number = typeof value === "number" ? value : 0;
return (
<ProgressPrimitive.Root
data-slot="progress"
value={progressValue}
className={cn("progress-track relative w-full overflow-hidden", className)}
{...props}>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="progress-indicator h-full w-full flex-1 transition-all"
style={{
transform: `translateX(-${String(100 - progressValue)}%)`,
}}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -3,7 +3,7 @@ import { Label } from "./label";
import { RadioGroup, RadioGroupItem } from "./radio-group";
const meta: Meta<typeof RadioGroup> = {
title: "UI-package/RadioGroup",
title: "UI-package/General/RadioGroup",
component: RadioGroup,
parameters: {
layout: "centered",
@@ -144,7 +144,7 @@ export const PaymentMethod: Story = {
),
};
export const SurveyQuestion: Story = {
export const SurveyElement: Story = {
render: () => (
<div className="w-[400px] space-y-4">
<div>

View File

@@ -3,7 +3,7 @@
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { AlertCircle, CircleIcon } from "lucide-react";
import * as React from "react";
import { cn } from "../lib/utils";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
@@ -15,9 +15,9 @@ function RadioGroup({
dir?: "ltr" | "rtl";
}): React.JSX.Element {
return (
<div className="flex gap-2" dir={dir}>
<div className="flex w-full gap-2" dir={dir}>
{errorMessage ? <div className="bg-destructive min-h-full w-1" /> : null}
<div className="space-y-2">
<div className="w-full space-y-2">
{errorMessage ? (
<div className="text-destructive flex items-center gap-1 text-sm">
<AlertCircle className="size-4" />
@@ -28,7 +28,7 @@ function RadioGroup({
aria-invalid={Boolean(errorMessage)}
data-slot="radio-group"
dir={dir}
className={cn("grid gap-3", className)}
className={className}
{...props}
/>
</div>
@@ -44,14 +44,17 @@ function RadioGroupItem({
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-primary text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 shadow-xs aspect-square size-4 shrink-0 rounded-full border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input-border text-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 shadow-xs aspect-square size-4 shrink-0 rounded-full border bg-white outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center">
<CircleIcon className="fill-primary absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2" />
<CircleIcon
fill="currentColor"
className="absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2"
/>
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);

View File

@@ -0,0 +1,466 @@
import type { FunctionComponent } from "react";
export const TiredFace: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m21.88 23.92c5.102-0.06134 7.273-1.882 8.383-3.346"
/>
<path
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
d="m46.24 47.56c0-2.592-2.867-7.121-10.25-6.93-6.974 0.1812-10.22 4.518-10.22 7.111s4.271-1.611 10.05-1.492c6.317 0.13 10.43 3.903 10.43 1.311z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m23.16 28.47c5.215 1.438 5.603 0.9096 8.204 1.207 1.068 0.1221-2.03 2.67-7.282 4.397"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m50.12 23.92c-5.102-0.06134-7.273-1.882-8.383-3.346"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m48.84 28.47c-5.215 1.438-5.603 0.9096-8.204 1.207-1.068 0.1221 2.03 2.67 7.282 4.397"
/>
</g>
</svg>
);
};
export const WearyFace: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m22.88 23.92c5.102-0.06134 7.273-1.882 8.383-3.346"
/>
<path
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
d="m46.24 47.56c0-2.592-2.867-7.121-10.25-6.93-6.974 0.1812-10.22 4.518-10.22 7.111s4.271-1.611 10.05-1.492c6.317 0.13 10.43 3.903 10.43 1.311z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m49.12 23.92c-5.102-0.06134-7.273-1.882-8.383-3.346"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m48.24 30.51c-6.199 1.47-7.079 1.059-8.868-1.961"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m23.76 30.51c6.199 1.47 7.079 1.059 8.868-1.961"
/>
</g>
</svg>
);
};
export const PerseveringFace: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<line
x1="44.5361"
x2="50.9214"
y1="21.4389"
y2="24.7158"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
/>
<line
x1="26.9214"
x2="20.5361"
y1="21.4389"
y2="24.7158"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M24,28c2.3334,1.3333,4.6666,2.6667,7,4c-2.3334,1.3333-4.6666,2.6667-7,4"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M48,28c-2.3334,1.3333-4.6666,2.6667-7,4c2.3334,1.3333,4.6666,2.6667,7,4"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M28,51c0.2704-0.3562,1-8,8.4211-8.0038C43,42.9929,43.6499,50.5372,44,51C38.6667,51,33.3333,51,28,51z"
/>
</g>
</svg>
);
};
export const FrowningFace: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M26.5,48c1.8768-3.8326,5.8239-6.1965,10-6c3.8343,0.1804,7.2926,2.4926,9,6"
/>
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
</g>
</svg>
);
};
export const ConfusedFace: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="m44.7 43.92c-6.328-1.736-11.41-0.906-17.4 1.902"
/>
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
</g>
</svg>
);
};
export const NeutralFace: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<line
x1="27"
x2="45"
y1="43"
y2="43"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
/>
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
</g>
</svg>
);
};
export const SlightlySmilingFace: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M45.8149,44.9293 c-2.8995,1.6362-6.2482,2.5699-9.8149,2.5699s-6.9153-0.9336-9.8149-2.5699"
/>
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
</g>
</svg>
);
};
export const SmilingFaceWithSmilingEyes: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M45.8147,45.2268a15.4294,15.4294,0,0,1-19.6294,0"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M31.6941,33.4036a4.7262,4.7262,0,0,0-8.6382,0"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M48.9441,33.4036a4.7262,4.7262,0,0,0-8.6382,0"
/>
</g>
</svg>
);
};
export const GrinningFaceWithSmilingEyes: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M50.595,41.64a11.5554,11.5554,0,0,1-.87,4.49c-12.49,3.03-25.43.34-27.49-.13a11.4347,11.4347,0,0,1-.83-4.36h.11s14.8,3.59,28.89.07Z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M49.7251,46.13c-1.79,4.27-6.35,7.23-13.69,7.23-7.41,0-12.03-3.03-13.8-7.36C24.2951,46.47,37.235,49.16,49.7251,46.13Z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M31.6941,32.4036a4.7262,4.7262,0,0,0-8.6382,0"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M48.9441,32.4036a4.7262,4.7262,0,0,0-8.6382,0"
/>
</g>
</svg>
);
};
export const GrinningSquintingFace: FunctionComponent<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
/>
<polyline
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
points="25.168 27.413 31.755 31.427 25.168 35.165"
/>
<polyline
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
points="46.832 27.413 40.245 31.427 46.832 35.165"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M50.595,41.64a11.5554,11.5554,0,0,1-.87,4.49c-12.49,3.03-25.43.34-27.49-.13a11.4347,11.4347,0,0,1-.83-4.36h.11s14.8,3.59,28.89.07Z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M49.7251,46.13c-1.79,4.27-6.35,7.23-13.69,7.23-7.41,0-12.03-3.03-13.8-7.36C24.2951,46.47,37.235,49.16,49.7251,46.13Z"
/>
</g>
</svg>
);
};
export const icons = [<svg key="smiley" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" />];

View File

@@ -3,7 +3,7 @@ import { Label } from "./label";
import { Textarea } from "./textarea";
const meta: Meta<typeof Textarea> = {
title: "UI-package/Textarea",
title: "UI-package/General/Textarea",
component: Textarea,
parameters: {
layout: "centered",

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import { ElementError } from "@/components/general/element-error";
import { cn } from "@/lib/utils";
type TextareaProps = React.ComponentProps<"textarea"> & {
dir?: "ltr" | "rtl" | "auto";
errorMessage?: string;
};
function Textarea({ className, errorMessage, dir = "auto", ...props }: TextareaProps): React.JSX.Element {
const hasError = Boolean(errorMessage);
return (
<div className="relative space-y-2">
<ElementError errorMessage={errorMessage} dir={dir} />
<textarea
data-slot="textarea"
dir={dir}
aria-invalid={hasError || undefined}
className={cn(
"w-input h-input bg-input-bg border-input-border rounded-input font-input font-input-weight text-input-text px-input-x py-input-y shadow-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content text-input flex min-h-16 border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
export { Textarea };

View File

@@ -1,72 +0,0 @@
import { AlertCircle } from "lucide-react";
import * as React from "react";
import { cn } from "../lib/utils";
interface InputProps extends React.ComponentProps<"input"> {
/** Text direction for RTL language support */
dir?: "ltr" | "rtl";
/** Error message to display above the input */
errorMessage?: string;
}
function Input({ className, type, errorMessage, dir, ...props }: InputProps): React.JSX.Element {
const hasError = Boolean(errorMessage);
// Default styles driven by CSS variables
const cssVarStyles: React.CSSProperties = {
width: "var(--fb-input-width)",
height: "var(--fb-input-height)",
backgroundColor: "var(--fb-input-bg-color)",
borderColor: "var(--fb-input-border-color)",
borderRadius: "var(--fb-input-border-radius)",
fontFamily: "var(--fb-input-font-family)",
fontSize: "var(--fb-input-font-size)",
fontWeight: "var(--fb-input-font-weight)" as React.CSSProperties["fontWeight"],
color: "var(--fb-input-color)",
paddingLeft: "var(--fb-input-padding-x)",
paddingRight: "var(--fb-input-padding-x)",
paddingTop: "var(--fb-input-padding-y)",
paddingBottom: "var(--fb-input-padding-y)",
boxShadow: "var(--fb-input-shadow)",
};
return (
<div className="space-y-1">
{errorMessage ? (
<div className="text-destructive flex items-center gap-1 text-sm" dir={dir}>
<AlertCircle className="size-4" />
<span>{errorMessage}</span>
</div>
) : null}
<input
type={type}
dir={dir}
style={cssVarStyles}
data-slot="input"
aria-invalid={hasError || undefined}
className={cn(
// Layout and behavior (Tailwind)
"flex min-w-0 border outline-none transition-[color,box-shadow]",
// Placeholder styling via CSS variable
"[&::placeholder]:opacity-[var(--fb-input-placeholder-opacity)]",
"placeholder:[color:var(--fb-input-placeholder-color)]",
// Selection styling
"selection:bg-primary selection:text-primary-foreground",
// File input specifics
"file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
// Focus ring
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
// Error state ring
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
// Disabled state
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
export { Input };
export type { InputProps };

View File

@@ -1,62 +0,0 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "../lib/utils";
interface LabelProps extends React.ComponentProps<typeof LabelPrimitive.Root> {
/** Label variant for different styling contexts */
variant?: "default" | "headline" | "description";
}
function Label({ className, variant = "default", ...props }: LabelProps): React.JSX.Element {
// Default styles driven by CSS variables based on variant
const getCssVarStyles = (): React.CSSProperties => {
if (variant === "headline") {
return {
fontFamily: "var(--fb-question-headline-font-family)",
fontWeight: "var(--fb-question-headline-font-weight)" as React.CSSProperties["fontWeight"],
fontSize: "var(--fb-question-headline-font-size)",
color: "var(--fb-question-headline-color)",
opacity: "var(--fb-question-headline-opacity)",
};
}
if (variant === "description") {
return {
fontFamily: "var(--fb-question-description-font-family)",
fontWeight: "var(--fb-question-description-font-weight)" as React.CSSProperties["fontWeight"],
fontSize: "var(--fb-question-description-font-size)",
color: "var(--fb-question-description-color)",
opacity: "var(--fb-question-description-opacity)",
};
}
// Default variant styles
return {
fontFamily: "var(--fb-label-font-family)",
fontWeight: "var(--fb-label-font-weight)" as React.CSSProperties["fontWeight"],
fontSize: "var(--fb-label-font-size)",
color: "var(--fb-label-color)",
opacity: "var(--fb-label-opacity)",
};
};
const cssVarStyles = getCssVarStyles();
return (
<LabelPrimitive.Root
data-slot="label"
data-variant={variant}
className={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className
)}
style={cssVarStyles}
{...props}
/>
);
}
export { Label };
export type { LabelProps };

View File

@@ -1,90 +0,0 @@
import { type Meta, type StoryObj } from "@storybook/react";
import { Progress } from "./progress";
const meta: Meta<typeof Progress> = {
title: "UI-package/Progress",
component: Progress,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
argTypes: {
value: {
control: { type: "range", min: 0, max: 100, step: 1 },
description: "Progress value (0-100)",
},
indicatorStyle: {
control: { type: "object" },
description: "Style for the progress indicator",
},
trackStyle: {
control: { type: "object" },
description: "Style for the progress track",
},
},
};
export default meta;
type Story = StoryObj<typeof Progress>;
export const Default: Story = {
render: (args: React.ComponentProps<typeof Progress>) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 50,
},
};
export const Zero: Story = {
render: (args: React.ComponentProps<typeof Progress>) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 0,
},
};
export const Half: Story = {
render: (args: React.ComponentProps<typeof Progress>) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 50,
},
};
export const Complete: Story = {
render: (args: React.ComponentProps<typeof Progress>) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 100,
},
};
export const CustomStyles: Story = {
render: (args: React.ComponentProps<typeof Progress>) => (
<div className="w-64">
<Progress {...args} />
</div>
),
args: {
value: 75,
indicatorStyle: {
backgroundColor: "green",
},
trackStyle: {
backgroundColor: "black",
height: "20px",
},
},
};

View File

@@ -1,39 +0,0 @@
"use client";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import * as React from "react";
import { cn } from "../lib/utils";
interface ProgressProps extends Omit<React.ComponentProps<"div">, "children"> {
/** Progress value (0-100) */
value?: number;
/** Custom inline styles for the progress indicator */
indicatorStyle?: React.CSSProperties;
/** Custom inline styles for the progress track */
trackStyle?: React.CSSProperties;
}
function Progress({
className,
value,
indicatorStyle,
trackStyle,
...props
}: ProgressProps): React.JSX.Element {
const progressValue: number = typeof value === "number" ? value : 0;
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
style={trackStyle}
{...props}>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${String(100 - progressValue)}%)`, ...indicatorStyle }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -1,37 +0,0 @@
import { AlertCircle } from "lucide-react";
import * as React from "react";
import { cn } from "../lib/utils";
type TextareaProps = React.ComponentProps<"textarea"> & {
dir?: "ltr" | "rtl";
errorMessage?: string;
style?: React.CSSProperties;
};
function Textarea({ className, errorMessage, dir, style, ...props }: TextareaProps): React.JSX.Element {
const hasError = Boolean(errorMessage);
return (
<div className="space-y-1">
{errorMessage ? (
<div className="text-destructive flex items-center gap-1 text-sm" dir={dir}>
<AlertCircle className="size-4" />
<span>{errorMessage}</span>
</div>
) : null}
<textarea
data-slot="textarea"
dir={dir}
style={style}
aria-invalid={hasError || undefined}
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
</div>
);
}
export { Textarea };

View File

@@ -1,2 +1,2 @@
// Export custom hooks here
export {};
export { useTextDirection, type UseTextDirectionOptions } from "@/hooks/use-text-direction";

View File

@@ -0,0 +1,103 @@
import { useMemo } from "react";
/**
* Detects whether a string contains RTL (right-to-left) or LTR (left-to-right) characters
* Returns 'rtl' if RTL characters are found, 'ltr' if LTR characters are found, 'neutral' if no directional characters
*
* @param text - The text to analyze
* @returns 'rtl' | 'ltr' | 'neutral'
*
* @example
* detectTextDirection("Hello world") // returns 'ltr'
* detectTextDirection("مرحبا بالعالم") // returns 'rtl'
* detectTextDirection("123 !#") // returns 'neutral'
*/
function detectTextDirection(text: string): "rtl" | "ltr" | "neutral" {
if (!text || text.trim().length === 0) {
return "neutral";
}
// Unicode ranges for RTL characters:
// Hebrew: \u0590-\u05FF
// Arabic: \u0600-\u06FF
// Arabic Supplement: \u0750-\u077F
// Arabic Extended-A: \u0800-\u083F
// Arabic Extended-B: \u0840-\u085F
// Syriac: \u0700-\u074F
// Thaana: \u0780-\u07BF
// Nko: \u07C0-\u07FF
const rtlPattern =
/[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u0800-\u083F\u0840-\u085F\u07C0-\u07FF]/g;
// Unicode ranges for LTR characters:
// Latin: \u0041-\u005A (A-Z), \u0061-\u007A (a-z)
// Latin Extended: \u0100-\u017F
// Latin Extended-A: \u0100-\u017F
// Latin Extended-B: \u0180-\u024F
const ltrPattern = /[A-Za-z\u0100-\u024F]/g;
const rtlMatch = text.match(rtlPattern);
const ltrMatch = text.match(ltrPattern);
// If both RTL and LTR characters are present, RTL takes precedence
if (rtlMatch && ltrMatch) {
return rtlMatch.length >= ltrMatch.length ? "rtl" : "ltr";
}
if (rtlMatch) {
return "rtl";
}
if (ltrMatch) {
return "ltr";
}
return "neutral";
}
export interface UseTextDirectionOptions {
/** Explicit direction prop (takes precedence over detection) */
dir?: "ltr" | "rtl" | "auto";
/** Text content to analyze for direction detection */
textContent?: string | string[];
}
/**
* Hook to detect and determine text direction for form elements
*
* @param options - Configuration options containing dir and textContent
* @returns The detected or provided text direction ("ltr" | "rtl" | undefined)
*
* @example
* ```tsx
* const dir = useTextDirection({
* dir: "auto",
* textContent: [headline, description, placeholder]
* });
* ```
*/
export function useTextDirection({ dir, textContent }: UseTextDirectionOptions): "ltr" | "rtl" | undefined {
return useMemo(() => {
// If explicit direction is provided and not "auto", use it
if (dir && dir !== "auto") {
return dir;
}
// If no text content provided, return undefined
if (!textContent) {
return undefined;
}
// Handle array of strings or single string
const textsToCheck = Array.isArray(textContent) ? textContent : [textContent];
// Find the first non-empty text
const textToCheck = textsToCheck.find((text) => text && text.trim().length > 0) ?? "";
// Detect direction from text
const detected = detectTextDirection(textToCheck);
// Convert "neutral" to undefined (browsers handle neutral as auto)
return detected === "neutral" ? undefined : detected;
}, [dir, textContent]);
}

View File

@@ -1,2 +1,47 @@
export { Button, buttonVariants } from "./components/button";
export { Input, type InputProps } from "./components/input";
export { Button, buttonVariants } from "@/components/general/button";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
} from "@/components/general/dropdown-menu";
export { ElementHeader, type ElementHeaderProps } from "@/components/general/element-header";
export { ElementMedia, type ElementMediaProps } from "@/components/general/element-media";
export { Input, type InputProps } from "@/components/general/input";
export { OpenText, type OpenTextProps } from "@/components/elements/open-text";
export {
MultiSelect,
type MultiSelectProps,
type MultiSelectOption,
} from "@/components/elements/multi-select";
export {
SingleSelect,
type SingleSelectProps,
type SingleSelectOption,
} from "@/components/elements/single-select";
export { Matrix, type MatrixProps, type MatrixOption } from "@/components/elements/matrix";
export { DateElement, type DateElementProps } from "@/components/elements/date";
export { getDateFnsLocale } from "@/lib/locale";
export {
PictureSelect,
type PictureSelectProps,
type PictureSelectOption,
} from "@/components/elements/picture-select";
export { FileUpload, type FileUploadProps, type UploadedFile } from "@/components/elements/file-upload";
export { FormField, type FormFieldProps, type FormFieldConfig } from "@/components/elements/form-field";
export { Rating, type RatingProps } from "@/components/elements/rating";
export { NPS, type NPSProps } from "@/components/elements/nps";
export { Ranking, type RankingProps, type RankingOption } from "@/components/elements/ranking";
export { CTA, type CTAProps } from "@/components/elements/cta";
export { Consent, type ConsentProps } from "@/components/elements/consent";

View File

@@ -0,0 +1,52 @@
import { type Locale } from "date-fns";
import { ar, de, enUS, es, fr, hi, it, ja, nl, pt, ptBR, ro, ru, uz, zhCN, zhTW } from "date-fns/locale";
/**
* Maps locale codes to date-fns locale objects
* Supports survey language codes (e.g., "en", "de", "ar", "zh-Hans") and
* common locale formats (e.g., "en-US", "de-DE", etc.)
*/
export function getDateFnsLocale(localeCode?: string): Locale {
if (!localeCode) {
return enUS; // Default to English (US)
}
const normalized = localeCode.toLowerCase();
// Handle special cases for full locale codes first
if (normalized.startsWith("pt-br")) {
return ptBR;
}
if (normalized.startsWith("pt-pt")) {
return pt;
}
if (normalized.startsWith("zh-hans") || normalized === "zh-cn") {
return zhCN;
}
if (normalized.startsWith("zh-hant") || normalized === "zh-tw" || normalized === "zh-hk") {
return zhTW;
}
// Extract base language code (e.g., "en-US" -> "en", "de-DE" -> "de")
const baseCode = normalized.split("-")[0];
// Map survey language codes to date-fns locales
const localeMap: Record<string, Locale> = {
en: enUS,
de,
es,
fr,
ja,
nl,
pt: ptBR, // Default Portuguese to Brazilian
ro,
ar,
it,
ru,
uz,
hi,
zh: zhCN, // Default Chinese to Simplified
};
return localeMap[baseCode] ?? enUS;
}

View File

@@ -0,0 +1,440 @@
import type { Decorator, StoryContext } from "@storybook/react";
import React, { useEffect, useState } from "react";
// ============================================================================
// Shared Styling Options Interfaces
// ============================================================================
export interface BaseStylingOptions {
// Element styling
elementHeadlineFontFamily: string;
elementHeadlineFontSize: string;
elementHeadlineFontWeight: string;
elementHeadlineColor: string;
elementDescriptionFontFamily: string;
elementDescriptionFontWeight: string;
elementDescriptionFontSize: string;
elementDescriptionColor: string;
// Input styling
inputBgColor: string;
inputBorderColor: string;
inputColor: string;
inputFontSize: string;
inputFontWeight: string;
// Survey styling
brandColor: string;
}
export interface LabelStylingOptions {
labelFontFamily: string;
labelFontSize: string;
labelFontWeight: string;
labelColor: string;
labelOpacity: string;
}
export interface InputLayoutStylingOptions {
inputWidth: string;
inputHeight: string;
inputBorderRadius: string;
inputPlaceholderColor: string;
inputPaddingX: string;
inputPaddingY: string;
}
export interface OptionStylingOptions {
optionBorderColor: string;
optionBgColor: string;
optionLabelColor: string;
optionBorderRadius: string;
optionPaddingX: string;
optionPaddingY: string;
optionFontFamily: string;
optionFontSize: string;
optionFontWeight: string;
}
export interface ButtonStylingOptions {
buttonHeight: string;
buttonWidth: string;
buttonFontSize: string;
buttonBorderRadius: string;
buttonBgColor: string;
buttonTextColor: string;
buttonPaddingX: string;
buttonPaddingY: string;
}
export interface CheckboxInputStylingOptions {
checkboxInputBorderColor: string;
checkboxInputBgColor: string;
checkboxInputColor: string;
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Selects a subset of argTypes by key, with type safety.
* Useful for stories that only need specific styling controls.
*
* @example
* pickArgTypes(inputStylingArgTypes, ['inputBgColor', 'inputBorderColor'])
*/
export function pickArgTypes<T extends Record<string, unknown>>(argTypes: T, keys: (keyof T)[]): Partial<T> {
const result: Partial<T> = {};
for (const key of keys) {
if (key in argTypes) {
result[key] = argTypes[key];
}
}
return result;
}
// ============================================================================
// Common argTypes Configurations
// ============================================================================
export const commonArgTypes = {
headline: {
control: "text",
description: "The main element text",
table: { category: "Content" },
},
description: {
control: "text",
description: "Optional description or subheader text",
table: { category: "Content" },
},
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 input is disabled",
table: { category: "State" },
},
onChange: {
action: "changed",
table: { category: "Events" },
},
};
export const elementStylingArgTypes = {
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" },
},
};
export const inputStylingArgTypes = {
inputBgColor: {
control: "color",
table: { category: "Input Styling" },
},
inputBorderColor: {
control: "color",
table: { category: "Input Styling" },
},
inputColor: {
control: "color",
table: { category: "Input Styling" },
},
inputFontSize: {
control: "text",
table: { category: "Input Styling" },
},
inputFontWeight: {
control: "text",
table: { category: "Input Styling" },
},
inputWidth: {
control: "text",
table: { category: "Input Styling" },
},
inputHeight: {
control: "text",
table: { category: "Input Styling" },
},
inputBorderRadius: {
control: "text",
table: { category: "Input Styling" },
},
inputPlaceholderColor: {
control: "color",
table: { category: "Input Styling" },
},
inputPaddingX: {
control: "text",
table: { category: "Input Styling" },
},
inputPaddingY: {
control: "text",
table: { category: "Input Styling" },
},
};
export const labelStylingArgTypes = {
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" },
},
labelOpacity: {
control: "text",
table: { category: "Label Styling" },
},
};
export const surveyStylingArgTypes = {
brandColor: {
control: "color",
table: { category: "Survey Styling" },
},
};
export const optionStylingArgTypes = {
optionBorderColor: {
control: "color",
table: { category: "Option Styling" },
},
optionBgColor: {
control: "color",
table: { category: "Option Styling" },
},
optionLabelColor: {
control: "color",
table: { category: "Option Styling" },
},
optionBorderRadius: {
control: "text",
table: { category: "Option Styling" },
},
optionPaddingX: {
control: "text",
table: { category: "Option Styling" },
},
optionPaddingY: {
control: "text",
table: { category: "Option Styling" },
},
optionFontFamily: {
control: "text",
table: { category: "Option Styling" },
},
optionFontSize: {
control: "text",
table: { category: "Option Styling" },
},
optionFontWeight: {
control: "text",
table: { category: "Option Styling" },
},
};
export const buttonStylingArgTypes = {
buttonHeight: {
control: "text",
table: { category: "Button Styling" },
},
buttonWidth: {
control: "text",
table: { category: "Button Styling" },
},
buttonFontSize: {
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" },
},
};
// ============================================================================
// CSS Variable Mapping and Decorator Factory
// ============================================================================
type CSSVarMapping = Record<string, string>;
const CSS_VAR_MAP: CSSVarMapping = {
elementHeadlineFontFamily: "--fb-element-headline-font-family",
elementHeadlineFontSize: "--fb-element-headline-font-size",
elementHeadlineFontWeight: "--fb-element-headline-font-weight",
elementHeadlineColor: "--fb-element-headline-color",
elementDescriptionFontFamily: "--fb-element-description-font-family",
elementDescriptionFontSize: "--fb-element-description-font-size",
elementDescriptionFontWeight: "--fb-element-description-font-weight",
elementDescriptionColor: "--fb-element-description-color",
inputBgColor: "--fb-input-bg-color",
inputBorderColor: "--fb-input-border-color",
inputColor: "--fb-input-color",
inputFontSize: "--fb-input-font-size",
inputFontWeight: "--fb-input-font-weight",
inputWidth: "--fb-input-width",
inputHeight: "--fb-input-height",
inputBorderRadius: "--fb-input-border-radius",
inputPlaceholderColor: "--fb-input-placeholder-color",
inputPaddingX: "--fb-input-padding-x",
inputPaddingY: "--fb-input-padding-y",
labelFontFamily: "--fb-label-font-family",
labelFontSize: "--fb-label-font-size",
labelFontWeight: "--fb-label-font-weight",
labelColor: "--fb-label-color",
labelOpacity: "--fb-label-opacity",
brandColor: "--fb-survey-brand-color",
optionBorderColor: "--fb-option-border-color",
optionBgColor: "--fb-option-bg-color",
optionLabelColor: "--fb-option-label-color",
optionBorderRadius: "--fb-option-border-radius",
optionPaddingX: "--fb-option-padding-x",
optionPaddingY: "--fb-option-padding-y",
optionFontFamily: "--fb-option-font-family",
optionFontSize: "--fb-option-font-size",
optionFontWeight: "--fb-option-font-weight",
buttonHeight: "--fb-button-height",
buttonWidth: "--fb-button-width",
buttonFontSize: "--fb-button-font-size",
buttonBorderRadius: "--fb-button-border-radius",
buttonBgColor: "--fb-button-bg-color",
buttonTextColor: "--fb-button-text-color",
buttonPaddingX: "--fb-button-padding-x",
buttonPaddingY: "--fb-button-padding-y",
checkboxInputBorderColor: "--fb-checkbox-input-border-color",
checkboxInputBgColor: "--fb-checkbox-input-bg-color",
checkboxInputColor: "--fb-checkbox-input-color",
};
export function createCSSVariablesDecorator<T extends Record<string, unknown> = Record<string, unknown>>(
width = "600px",
additionalMappings?: CSSVarMapping
): Decorator<T> {
const fullMapping = { ...CSS_VAR_MAP, ...additionalMappings };
function CSSVariablesDecorator(Story: React.ComponentType, context: StoryContext<T>): React.ReactElement {
// Storybook's Decorator type doesn't properly infer args type, so we safely extract it
// Access args through a type-safe helper
interface ContextWithArgs {
args?: T;
}
const safeContext = context as unknown as ContextWithArgs;
const args = (safeContext.args ?? {}) as Record<string, string | undefined>;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {};
Object.entries(fullMapping).forEach(([argKey, cssVar]) => {
if (args[argKey] !== undefined) {
cssVarStyle[cssVar] = args[argKey];
}
});
return (
<div style={cssVarStyle} className={`w-[${width}]`}>
<Story />
</div>
);
}
CSSVariablesDecorator.displayName = "CSSVariablesDecorator";
return CSSVariablesDecorator;
}
// ============================================================================
// Stateful Render Function Creator
// ============================================================================
export function createStatefulRender<
TValue,
TProps extends { value?: TValue; onChange?: (v: TValue) => void },
>(Component: React.ComponentType<TProps>): (args: TProps & Record<string, unknown>) => React.ReactElement {
function StatefulRender(args: TProps & Record<string, unknown>): React.ReactElement {
const [value, setValue] = useState<TValue | undefined>(args.value);
useEffect(() => {
setValue(args.value);
}, [args.value]);
return (
<Component
{...args}
value={value}
onChange={(v: TValue) => {
setValue(v);
args.onChange?.(v);
}}
/>
);
}
StatefulRender.displayName = "StatefulRender";
return StatefulRender;
}

View File

@@ -1,5 +1,13 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({
theme: {
// Custom tokens from `packages/survey-ui/tailwind.config.ts`
fontSize: ["input", "option", "button"],
textColor: ["input-text", "input-placeholder", "option-label", "button-text"],
},
});
/**
* Utility function to merge Tailwind CSS classes

View File

@@ -0,0 +1,121 @@
export const checkForYoutubeUrl = (url: string): boolean => {
try {
const youtubeUrl = new URL(url);
if (youtubeUrl.protocol !== "https:") return false;
const youtubeDomains = [
"www.youtube.com",
"www.youtu.be",
"www.youtube-nocookie.com",
"youtube.com",
"youtu.be",
"youtube-nocookie.com",
];
const hostname = youtubeUrl.hostname;
return youtubeDomains.includes(hostname);
} catch (err) {
// invalid URL
return false;
}
};
export const checkForVimeoUrl = (url: string): boolean => {
try {
const vimeoUrl = new URL(url);
if (vimeoUrl.protocol !== "https:") return false;
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
} catch (err) {
// invalid URL
return false;
}
};
export const checkForLoomUrl = (url: string): boolean => {
try {
const loomUrl = new URL(url);
if (loomUrl.protocol !== "https:") return false;
const loomDomains = ["www.loom.com", "loom.com"];
const hostname = loomUrl.hostname;
return loomDomains.includes(hostname);
} catch (err) {
// invalid URL
return false;
}
};
const extractYoutubeId = (url: string): string | null => {
let id = "";
// Regular expressions for various YouTube URL formats
const regExpList = [
/youtu\.be\/(?<videoId>[a-zA-Z0-9_-]+)/, // youtu.be/<id>
/youtube\.com.*v=(?<videoId>[a-zA-Z0-9_-]+)/, // youtube.com/watch?v=<id>
/youtube\.com.*embed\/(?<videoId>[a-zA-Z0-9_-]+)/, // youtube.com/embed/<id>
/youtube-nocookie\.com\/embed\/(?<videoId>[a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/<id>
];
regExpList.some((regExp) => {
const match = url.match(regExp);
if (match?.groups?.videoId) {
id = match.groups.videoId;
return true;
}
return false;
});
return id || null;
};
const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(?<videoId>\d+)/;
const match = regExp.exec(url);
return match?.groups?.videoId ?? null;
};
const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/(?<videoId>[a-zA-Z0-9]+)/;
const match = regExp.exec(url);
return match?.groups?.videoId ?? null;
};
// Always convert a given URL into its embed form if supported.
export const convertToEmbedUrl = (url: string): string | undefined => {
// YouTube
if (checkForYoutubeUrl(url)) {
const videoId = extractYoutubeId(url);
if (videoId) {
return `https://www.youtube.com/embed/${videoId}`;
}
}
// Vimeo
if (checkForVimeoUrl(url)) {
const videoId = extractVimeoId(url);
if (videoId) {
return `https://player.vimeo.com/video/${videoId}`;
}
}
// Loom
if (checkForLoomUrl(url)) {
const videoId = extractLoomId(url);
if (videoId) {
return `https://www.loom.com/embed/${videoId}`;
}
}
// If no supported platform found, return undefined
return undefined;
};

View File

@@ -1,57 +1,223 @@
/* =============================================================================
Survey UI - Global Styles
This file contains:
1. Tailwind CSS setup and configuration
2. Base design tokens (colors, radius)
3. Survey-specific theming tokens (--fb-* variables)
All tokens are designed to be overridden by consumers for custom theming.
Components use Tailwind utilities backed by these CSS variables.
============================================================================= */
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities) important;
@config "../../tailwind.config.ts";
@source "../../src/**/*.{ts,tsx,js,jsx}";
/* =============================================================================
Design Tokens
============================================================================= */
:root {
/* Base variables used by other variables */
--foreground: hsl(222.2 84% 4.9%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--input: hsl(214.3 31.8% 91.4%);
--radius: 0.5rem;
/* ---------------------------------------------------------------------------
Base Primitives
These are the foundation tokens used throughout the design system.
--------------------------------------------------------------------------- */
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-muted: oklch(0.97 0.02 27.325);
--border: oklch(0.922 0 0);
--input: black;
/* Question Headline CSS variables */
--fb-question-headline-font-family: inherit;
--fb-question-headline-font-weight: 600;
--fb-question-headline-font-size: 1.125rem;
--fb-question-headline-color: hsl(222.2 84% 4.9%);
--fb-question-headline-opacity: 1;
/* ---------------------------------------------------------------------------
Survey Brand Color
The primary accent color used throughout the survey. Override this to
customize the brand appearance.
--------------------------------------------------------------------------- */
--fb-survey-brand-color: #64748b;
/* Question Description CSS variables */
--fb-question-description-font-family: inherit;
--fb-question-description-font-weight: 400;
--fb-question-description-font-size: 0.875rem;
--fb-question-description-color: hsl(215.4 16.3% 46.9%);
--fb-question-description-opacity: 1;
/* ---------------------------------------------------------------------------
Element Headline Tokens
Used for question headlines and main element titles.
--------------------------------------------------------------------------- */
--fb-element-headline-font-family: inherit;
--fb-element-headline-font-weight: 600;
--fb-element-headline-font-size: 1.125rem;
--fb-element-headline-color: hsl(222.2 84% 4.9%);
--fb-element-headline-opacity: 1;
/* Label CSS variables */
--fb-label-font-family: inherit;
--fb-label-font-weight: 500;
--fb-label-font-size: 0.875rem;
--fb-label-color: var(--foreground);
--fb-label-opacity: 1;
/* ---------------------------------------------------------------------------
Element Description Tokens
Used for descriptive text below headlines.
--------------------------------------------------------------------------- */
--fb-element-description-font-family: inherit;
--fb-element-description-font-weight: 400;
--fb-element-description-font-size: 0.875rem;
--fb-element-description-color: hsl(215.4 16.3% 46.9%);
--fb-element-description-opacity: 1;
/* Button CSS variables */
--fb-button-height: 2.25rem;
--fb-button-width: auto;
--fb-button-font-size: 0.875rem;
--fb-button-border-radius: var(--radius);
--fb-button-bg-color: hsl(222.2 47.4% 11.2%);
--fb-button-text-color: hsl(210 40% 98%);
--fb-button-padding-x: 1rem;
--fb-button-padding-y: 0.5rem;
/* ---------------------------------------------------------------------------
Label Tokens
Used for form labels and secondary text.
--------------------------------------------------------------------------- */
--fb-label-font-family: inherit;
--fb-label-font-weight: 500;
--fb-label-font-size: 0.875rem;
--fb-label-color: var(--foreground);
--fb-label-opacity: 1;
/* Input CSS variables */
--fb-input-bg-color: transparent;
--fb-input-bg-opacity: 1;
--fb-input-hover-bg-color: var(--input);
--fb-input-hover-bg-opacity: 0.3;
--fb-input-border-color: var(--input);
--fb-input-border-radius: var(--radius);
--fb-input-font-family: inherit;
--fb-input-font-size: 0.875rem;
--fb-input-font-weight: 400;
--fb-input-color: var(--foreground);
--fb-input-placeholder-color: var(--muted-foreground);
--fb-input-placeholder-opacity: 1;
--fb-input-width: 100%;
--fb-input-height: 2.25rem;
--fb-input-padding-x: 0.75rem;
--fb-input-padding-y: 0.25rem;
--fb-input-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
/* ---------------------------------------------------------------------------
Button Tokens
Used for the custom button variant. Standard variants use Tailwind defaults.
--------------------------------------------------------------------------- */
--fb-button-height: 2.25rem;
--fb-button-width: auto;
--fb-button-font-size: 0.875rem;
--fb-button-font-family: inherit;
--fb-button-font-weight: 500;
--fb-button-border-radius: var(--radius);
--fb-button-bg-color: hsl(222.2 47.4% 11.2%);
--fb-button-text-color: hsl(210 40% 98%);
--fb-button-padding-x: 1rem;
--fb-button-padding-y: 0.5rem;
/* ---------------------------------------------------------------------------
Input Tokens
Used for text inputs, textareas, and other form controls.
--------------------------------------------------------------------------- */
--fb-input-bg-color: white;
--fb-input-border-color: var(--fb-survey-brand-color);
--fb-input-border-radius: var(--radius);
--fb-input-font-family: inherit;
--fb-input-font-size: 0.875rem;
--fb-input-font-weight: 400;
--fb-input-color: var(--foreground);
--fb-input-placeholder-color: var(--muted-foreground);
--fb-input-placeholder-opacity: 1;
--fb-input-width: 100%;
--fb-input-height: 40px;
--fb-input-padding-x: 16px;
--fb-input-padding-y: 16px;
--fb-input-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
/* ---------------------------------------------------------------------------
Progress Tokens
Used for the Progress component track and indicator.
--------------------------------------------------------------------------- */
--fb-progress-track-height: 0.5rem;
--fb-progress-track-bg-color: hsl(222.2 47.4% 11.2% / 0.2);
--fb-progress-track-border-radius: var(--radius);
--fb-progress-indicator-bg-color: hsl(222.2 47.4% 11.2%);
--fb-progress-indicator-border-radius: var(--radius);
/* ---------------------------------------------------------------------------
Option Tokens
Used for selectable options (radio, checkbox, multi-select).
These inherit from input tokens by default for consistency.
--------------------------------------------------------------------------- */
--fb-option-border-color: var(--fb-input-border-color);
--fb-option-bg-color: var(--fb-input-bg-color);
--fb-option-label-color: var(--fb-input-color);
--fb-option-border-radius: var(--fb-input-border-radius);
--fb-option-padding-x: var(--fb-input-padding-x);
--fb-option-padding-y: var(--fb-input-padding-y);
--fb-option-font-family: var(--fb-input-font-family);
--fb-option-font-size: var(--fb-input-font-size);
--fb-option-font-weight: var(--fb-input-font-weight);
}
.button-custom {
width: var(--fb-button-width);
height: var(--fb-button-height);
background-color: var(--fb-button-bg-color);
border-radius: var(--fb-button-border-radius);
font-family: var(--fb-button-font-family);
font-weight: var(--fb-button-font-weight);
padding-left: var(--fb-button-padding-x);
padding-right: var(--fb-button-padding-x);
padding-top: var(--fb-button-padding-y);
padding-bottom: var(--fb-button-padding-y);
color: var(--fb-button-text-color);
font-size: var(--fb-button-font-size);
}
.label-headline {
font-family: var(--fb-element-headline-font-family);
font-weight: var(--fb-element-headline-font-weight);
font-size: var(--fb-element-headline-font-size);
color: var(--fb-element-headline-color);
opacity: var(--fb-element-headline-opacity);
}
.label-description {
font-family: var(--fb-element-description-font-family);
font-weight: var(--fb-element-description-font-weight);
font-size: var(--fb-element-description-font-size);
color: var(--fb-element-description-color);
opacity: var(--fb-element-description-opacity);
}
.label-default {
font-family: var(--fb-label-font-family);
font-weight: var(--fb-label-font-weight);
font-size: var(--fb-label-font-size);
color: var(--fb-label-color);
opacity: var(--fb-label-opacity);
}
.progress-track {
height: var(--fb-progress-track-height);
background-color: var(--fb-progress-track-bg-color);
border-radius: var(--fb-progress-track-border-radius);
}
.progress-indicator {
background-color: var(--fb-progress-indicator-bg-color);
border-radius: var(--fb-progress-indicator-border-radius);
}
/* ---------------------------------------------------------------------------
Textarea Scrollbar Styling
Smaller, more subtle scrollbars for textarea elements.
--------------------------------------------------------------------------- */
textarea {
/* Firefox */
scrollbar-width: thin;
scrollbar-color: hsl(215.4 16.3% 46.9% / 0.3) transparent;
}
/* Chrome, Edge, and Safari */
textarea::-webkit-scrollbar {
width: 4px;
height: 4px;
}
textarea::-webkit-scrollbar-track {
background: transparent;
}
textarea::-webkit-scrollbar-thumb {
background-color: hsl(215.4 16.3% 46.9% / 0.3);
border-radius: 2px;
}
textarea::-webkit-scrollbar-thumb:hover {
background-color: hsl(215.4 16.3% 46.9% / 0.5);
}

View File

@@ -1,6 +1,107 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
darkMode: "class",
content: ["./src/**/*.{tsx,ts,jsx,js}"],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
card: {
DEFAULT: "var(--card)",
foreground: "var(--card-foreground)",
},
popover: {
DEFAULT: "var(--popover)",
foreground: "var(--popover-foreground)",
},
primary: {
DEFAULT: "var(--primary)",
foreground: "var(--primary-foreground)",
},
secondary: {
DEFAULT: "var(--secondary)",
foreground: "var(--secondary-foreground)",
},
accent: {
DEFAULT: "var(--accent)",
foreground: "var(--accent-foreground)",
},
destructive: {
DEFAULT: "var(--destructive)",
foreground: "var(--foreground)",
muted: "var(--destructive-muted)",
},
muted: {
DEFAULT: "var(--muted)",
foreground: "var(--muted-foreground)",
},
border: "var(--border)",
input: "var(--input)",
ring: "var(--fb-survey-brand-color)",
brand: {
DEFAULT: "var(--fb-survey-brand-color)",
"20": "color-mix(in srgb, var(--fb-survey-brand-color) 20%, white)",
},
// Input CSS variables (shorter names)
"input-bg": "var(--fb-input-bg-color)",
"input-border": "var(--fb-input-border-color, var(--fb-survey-brand-color))",
"input-text": "var(--fb-input-color)",
"input-placeholder": "var(--fb-input-placeholder-color)",
// Option CSS variables
"option-bg": "var(--fb-option-bg-color)",
"option-border": "var(--fb-option-border-color)",
"option-label": "var(--fb-option-label-color)",
"option-selected-bg": "color-mix(in srgb, var(--fb-option-bg-color) 90%, black)",
"option-hover-bg": "color-mix(in srgb, var(--fb-option-bg-color) 90%, black)",
"input-selected-bg": "color-mix(in srgb, var(--fb-input-bg-color) 90%, black)",
// Button CSS variables
"button-bg": "var(--fb-button-bg-color)",
"button-text": "var(--fb-button-text-color)",
},
width: {
input: "var(--fb-input-width)",
button: "var(--fb-button-width)",
},
height: {
input: "var(--fb-input-height)",
button: "var(--fb-button-height)",
},
borderRadius: {
input: "var(--fb-input-border-radius)",
option: "var(--fb-option-border-radius)",
button: "var(--fb-button-border-radius)",
},
fontSize: {
input: "var(--fb-input-font-size)",
option: "var(--fb-option-font-size)",
button: "var(--fb-button-font-size)",
},
fontWeight: {
"input-weight": "var(--fb-input-font-weight)",
"option-weight": "var(--fb-option-font-weight)",
"button-weight": "var(--fb-button-font-weight)",
},
fontFamily: {
input: "var(--fb-input-font-family)",
option: "var(--fb-option-font-family)",
button: "var(--fb-button-font-family)",
},
padding: {
"input-x": "var(--fb-input-padding-x)",
"input-y": "var(--fb-input-padding-y)",
"option-x": "var(--fb-option-padding-x)",
"option-y": "var(--fb-option-padding-y)",
"button-x": "var(--fb-button-padding-x)",
"button-y": "var(--fb-button-padding-y)",
},
boxShadow: {
input: "var(--fb-input-shadow)",
},
opacity: {
"input-placeholder": "var(--fb-input-placeholder-opacity)",
},
},
},
} satisfies Config;

View File

@@ -1,5 +1,6 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
build: {
@@ -12,6 +13,6 @@ export default defineConfig({
external: ["react", "react-dom"],
},
},
plugins: [dts({ include: ["src"] })],
plugins: [tsconfigPaths(), dts({ include: ["src"] })],
});

2574
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff