mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 11:59:54 -06:00
feat: improved survey UI (#6988)
Co-authored-by: Matti Nannt <matti@formbricks.com> Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
committed by
GitHub
parent
3ce07edf43
commit
15dc83a4eb
352
.cursor/commands/create-question.md
Normal file
352
.cursor/commands/create-question.md
Normal 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
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join } from "path";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
options: {},
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
|
||||
const rootPath = resolve(__dirname, "../../../");
|
||||
|
||||
// Configure server to allow files from outside the storybook directory
|
||||
config.server = config.server || {};
|
||||
config.server.fs = {
|
||||
...config.server.fs,
|
||||
allow: [...(config.server.fs?.allow || []), rootPath],
|
||||
};
|
||||
|
||||
// Configure simple alias resolution
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
"@": surveyUiPath,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import { I18nProvider } from "../../web/lingodotdev/client";
|
||||
import "../../web/modules/ui/globals.css";
|
||||
|
||||
// Create a Storybook-specific Lingodot Dev decorator
|
||||
const withLingodotDev = (Story: any) => {
|
||||
return React.createElement(
|
||||
I18nProvider,
|
||||
{
|
||||
language: "en-US",
|
||||
defaultLanguage: "en-US",
|
||||
} as any,
|
||||
React.createElement(Story)
|
||||
);
|
||||
};
|
||||
import "../../../packages/survey-ui/src/styles/globals.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
@@ -22,9 +9,23 @@ const preview: Preview = {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
expanded: true,
|
||||
},
|
||||
backgrounds: {
|
||||
default: "light",
|
||||
},
|
||||
},
|
||||
decorators: [withLingodotDev],
|
||||
decorators: [
|
||||
(Story) =>
|
||||
React.createElement(
|
||||
"div",
|
||||
{
|
||||
id: "fbjs",
|
||||
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
|
||||
},
|
||||
React.createElement(Story)
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -11,22 +11,24 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
"@formbricks/survey-ui": "workspace:*",
|
||||
"eslint-plugin-react-refresh": "0.4.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.0.1",
|
||||
"@storybook/addon-a11y": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/react-vite": "9.0.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@storybook/addon-a11y": "10.0.8",
|
||||
"@storybook/addon-links": "10.0.8",
|
||||
"@storybook/addon-onboarding": "10.0.8",
|
||||
"@storybook/react-vite": "10.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.48.0",
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@typescript-eslint/parser": "8.48.0",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"esbuild": "0.27.0",
|
||||
"eslint-plugin-storybook": "10.0.8",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "9.0.15",
|
||||
"vite": "6.4.1",
|
||||
"@storybook/addon-docs": "9.0.15"
|
||||
"storybook": "10.0.8",
|
||||
"vite": "7.2.4",
|
||||
"@storybook/addon-docs": "10.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
import base from "../web/tailwind.config";
|
||||
import surveyUi from "../../packages/survey-ui/tailwind.config";
|
||||
|
||||
export default {
|
||||
...base,
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
...surveyUi.theme?.extend,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
"process.env": {},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "../web"),
|
||||
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
|
||||
return (
|
||||
<Container>
|
||||
{overrideFileUploadResponse ? (
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap italic">
|
||||
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -65,6 +65,6 @@ export const renderEmailResponseValue = async (
|
||||
);
|
||||
|
||||
default:
|
||||
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response}</Text>;
|
||||
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function ResponseFinishedEmail({
|
||||
)}
|
||||
{variable.name}
|
||||
</Text>
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
|
||||
<Text className="mt-0 font-medium break-words whitespace-pre-wrap">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -94,7 +94,7 @@ export async function ResponseFinishedEmail({
|
||||
<Text className="mb-2 flex items-center gap-2 text-sm">
|
||||
{hiddenFieldId} <EyeOffIcon />
|
||||
</Text>
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
@@ -284,7 +284,7 @@ export const BlockCard = ({
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="opacity-0 hover:cursor-move group-hover:opacity-100"
|
||||
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
|
||||
aria-label="Drag to reorder block">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -400,7 +400,7 @@ export const SurveyMenuBar = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
|
||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
||||
{!isStorageConfigured && (
|
||||
<div>
|
||||
<Alert variant="warning" size="small">
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
|
||||
? `${t("emails.number_variable")}: ${variable.name}`
|
||||
: `${t("emails.text_variable")}: ${variable.name}`}
|
||||
</Text>
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -107,7 +107,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
|
||||
<Text className="mb-2 text-sm font-semibold text-slate-900">
|
||||
{t("emails.hidden_field")}: {hiddenFieldId}
|
||||
</Text>
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
@@ -155,7 +155,7 @@ export const FollowUpItem = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="absolute right-4 top-4 flex items-center">
|
||||
<div className="absolute top-4 right-4 flex items-center">
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 focus:outline-none hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
@@ -52,7 +52,7 @@ const SelectLabel: React.ComponentType<SelectPrimitive.SelectLabelProps> = React
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
|
||||
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -65,7 +65,7 @@ const SelectItem: React.ComponentType<SelectPrimitive.SelectItemProps> = React.f
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-pointer items-center rounded-md py-1.5 pr-2 pl-2 text-sm font-medium outline-none select-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -72,8 +72,8 @@
|
||||
"@radix-ui/react-tooltip": "1.2.6",
|
||||
"@react-email/components": "0.0.38",
|
||||
"@sentry/nextjs": "10.5.0",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@ungap/structured-clone": "1.3.0",
|
||||
@@ -116,6 +116,7 @@
|
||||
"react-day-picker": "9.6.7",
|
||||
"react-hook-form": "7.56.2",
|
||||
"react-hot-toast": "2.5.2",
|
||||
"react-calendar": "5.1.0",
|
||||
"react-i18next": "15.7.3",
|
||||
"react-turnstile": "1.1.4",
|
||||
"react-use": "17.6.0",
|
||||
|
||||
@@ -115,12 +115,12 @@ test.describe("JS Package Test", async () => {
|
||||
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
|
||||
await page
|
||||
.locator("#questionCard-3")
|
||||
.getByLabel("textarea")
|
||||
.getByRole("textbox")
|
||||
.fill("People who believe that PMF is necessary");
|
||||
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
|
||||
await page.locator("#questionCard-4").getByRole("textbox").fill("Much higher response rates!");
|
||||
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
|
||||
await page.locator("#questionCard-5").getByRole("textbox").fill("Make this end to end test pass!");
|
||||
await page.locator("#questionCard-5").getByRole("button", { name: "Finish" }).click();
|
||||
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
|
||||
@@ -113,10 +113,12 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(
|
||||
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
|
||||
// Rating component uses fieldset with labels, not a group with name "Choices"
|
||||
expect(await page.locator("#questionCard-3").locator("fieldset label").count()).toBe(5);
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("radio", { name: "Rate 3 out of" }).check();
|
||||
// Click on the label instead of the radio to avoid SVG intercepting pointer events
|
||||
await page.locator("#questionCard-3").locator('label:has(input[value="3"])').click();
|
||||
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// NPS Question
|
||||
@@ -165,9 +167,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(page.getByText(surveys.createAndSubmit.fileUploadQuestion.question)).toBeVisible();
|
||||
await expect(page.locator("#questionCard-8").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-8").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Upload files by clicking or" })).toBeVisible();
|
||||
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.doc",
|
||||
@@ -191,22 +191,22 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
page.getByRole("rowheader", { name: surveys.createAndSubmit.matrix.rows[2] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[0] })
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0], exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[1] })
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1], exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[2] })
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2], exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[3] })
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3], exact: true })
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
|
||||
await page.getByRole("radio", { name: "Roses-0" }).click();
|
||||
await page.getByRole("radio", { name: "Trees-0" }).click();
|
||||
await page.getByRole("radio", { name: "Ocean-0" }).click();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Address Question
|
||||
@@ -858,7 +858,8 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("radio", { name: "Rate 4 out of" }).check();
|
||||
// Click on the label instead of the radio to avoid SVG intercepting pointer events
|
||||
await page.locator("#questionCard-4").locator('label:has(input[value="4"])').click();
|
||||
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// NPS Question
|
||||
@@ -895,22 +896,22 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
page.getByRole("rowheader", { name: surveys.createWithLogicAndSubmit.matrix.rows[2] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[0] })
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0], exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[1] })
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1], exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[2] })
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2], exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[3] })
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3], exact: true })
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
|
||||
await page.getByRole("radio", { name: "Roses-0" }).click();
|
||||
await page.getByRole("radio", { name: "Trees-0" }).click();
|
||||
await page.getByRole("radio", { name: "Ocean-0" }).click();
|
||||
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// CTA Question
|
||||
@@ -939,9 +940,9 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Upload files by clicking or" })).toBeVisible();
|
||||
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.doc",
|
||||
mimeType: "application/msword",
|
||||
@@ -952,11 +953,10 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
|
||||
// Date Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.date.question)).toBeVisible();
|
||||
await page.getByText("Select a date").click();
|
||||
const date = new Date().getDate();
|
||||
const month = new Date().toLocaleString("default", { month: "long" });
|
||||
await page.getByRole("button", { name: `${month} ${date},` }).click();
|
||||
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
|
||||
// Click the "Today" button in the date picker - matches format like "Today, Tuesday, December 16th,"
|
||||
await page.getByRole("button", { name: /^Today,/ }).click();
|
||||
await page.getByRole("button", { name: "Scroll to bottom" }).click();
|
||||
await page.locator("#questionCard-11").getByRole("button", { name: "Next", exact: true }).click();
|
||||
|
||||
// Cal Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.cal.question)).toBeVisible();
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
"i18n:validate": "pnpm scan-translations"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.2",
|
||||
"react-dom": "19.1.2"
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure/identity": "4.13.0",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type Prisma, PrismaClient } from "@prisma/client";
|
||||
import { exec } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { type Prisma, PrismaClient } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
5
packages/survey-ui/.eslintrc.cjs
Normal file
5
packages/survey-ui/.eslintrc.cjs
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
ignorePatterns: ["**/*.stories.tsx", "**/*.stories.ts", "story-helpers.tsx", "**/*.test.ts"],
|
||||
};
|
||||
|
||||
8
packages/survey-ui/.gitignore
vendored
Normal file
8
packages/survey-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.turbo
|
||||
coverage
|
||||
*.log
|
||||
src/**/*.d.ts
|
||||
src/**/*.d.ts.map
|
||||
|
||||
101
packages/survey-ui/README.md
Normal file
101
packages/survey-ui/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# @formbricks/survey-ui
|
||||
|
||||
Reusable UI components package for Formbricks applications.
|
||||
|
||||
## Installation
|
||||
|
||||
This package is part of the Formbricks monorepo and is available as a workspace dependency.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Button } from "@formbricks/survey-ui";
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<Button variant="default" size="default">
|
||||
Click me
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build the package
|
||||
pnpm build
|
||||
|
||||
# Watch mode for development
|
||||
pnpm dev
|
||||
|
||||
|
||||
# Lint
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── components/ # React components
|
||||
├── lib/ # Utility functions
|
||||
└── index.ts # Main entry point
|
||||
```
|
||||
|
||||
## Adding New Components
|
||||
|
||||
### Using shadcn CLI (Recommended)
|
||||
|
||||
This package is configured to work with shadcn/ui CLI. You can add components using:
|
||||
|
||||
```bash
|
||||
cd packages/survey-ui
|
||||
pnpm ui:add <component-name>
|
||||
```
|
||||
|
||||
**Important**: After adding a component, reorganize it into a folder structure:
|
||||
|
||||
For example:
|
||||
```bash
|
||||
pnpm ui:add button
|
||||
pnpm ui:organize button
|
||||
```
|
||||
|
||||
Then export the component from `src/components/index.ts`.
|
||||
|
||||
### Manual Component Creation
|
||||
|
||||
1. Create a new component directory under `src/components/<component-name>/`
|
||||
2. Create `index.tsx` inside that directory
|
||||
3. Export the component from `src/components/index.ts`
|
||||
4. The component will be available from the main package export
|
||||
|
||||
## Component Structure
|
||||
|
||||
Components follow this folder structure:
|
||||
|
||||
```text
|
||||
src/components/
|
||||
├── button.tsx
|
||||
├── button.stories.tsx
|
||||
```
|
||||
|
||||
## Theming
|
||||
|
||||
This package uses CSS variables for theming. The theme can be customized by modifying `src/styles/globals.css`.
|
||||
|
||||
Both light and dark modes are supported out of the box.
|
||||
|
||||
## CSS Scoping
|
||||
|
||||
By default, this package builds CSS scoped to `#fbjs` for use in the surveys package. This ensures proper specificity and prevents conflicts with preflight CSS.
|
||||
|
||||
To build unscoped CSS (e.g., for standalone usage or Storybook), set the `SURVEY_UI_UNSCOPED` environment variable:
|
||||
|
||||
```bash
|
||||
SURVEY_UI_UNSCOPED=true pnpm build
|
||||
```
|
||||
|
||||
**Note:** Storybook imports the source CSS directly and compiles it with its own Tailwind config, so it's not affected by this scoping setting.
|
||||
|
||||
20
packages/survey-ui/components.json
Normal file
20
packages/survey-ui/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"ui": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
},
|
||||
"rsc": false,
|
||||
"style": "new-york",
|
||||
"tailwind": {
|
||||
"baseColor": "slate",
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"tsx": true
|
||||
}
|
||||
80
packages/survey-ui/package.json
Normal file
80
packages/survey-ui/package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "@formbricks/survey-ui",
|
||||
"license": "MIT",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Reusable UI components for Formbricks applications",
|
||||
"homepage": "https://formbricks.com",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"source": "src/index.ts",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./styles": "./dist/survey-ui.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode dev",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"go": "vite build --watch --mode dev",
|
||||
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist coverage",
|
||||
"ui:add": "npx shadcn@latest add",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "0.8.2",
|
||||
"@radix-ui/react-checkbox": "1.3.1",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.14",
|
||||
"@radix-ui/react-popover": "1.1.13",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"isomorphic-dompurify": "2.33.0",
|
||||
"lucide-react": "0.507.0",
|
||||
"react-day-picker": "9.6.7",
|
||||
"tailwind-merge": "3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@storybook/react": "8.5.4",
|
||||
"@storybook/react-vite": "8.5.4",
|
||||
"@tailwindcss/postcss": "4.0.0",
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@types/react": "19.2.1",
|
||||
"@types/react-dom": "19.2.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"rimraf": "6.0.1",
|
||||
"tailwindcss": "4.1.1",
|
||||
"vite": "6.4.1",
|
||||
"vite-plugin-dts": "4.5.3",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"@vitest/coverage-v8": "3.1.3",
|
||||
"vitest": "3.1.3"
|
||||
}
|
||||
}
|
||||
5
packages/survey-ui/postcss.config.mjs
Normal file
5
packages/survey-ui/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
188
packages/survey-ui/src/components/elements/consent.stories.tsx
Normal file
188
packages/survey-ui/src/components/elements/consent.stories.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Consent, type ConsentProps } from "./consent";
|
||||
|
||||
type StoryProps = ConsentProps & Partial<BaseStylingOptions> & Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Consent",
|
||||
component: Consent,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A consent element that displays a checkbox for users to accept terms, conditions, or agreements.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
checkboxLabel: {
|
||||
control: "text",
|
||||
description: "Label text for the consent checkbox",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "boolean",
|
||||
description: "Whether consent is checked",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Consent),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "consent-1",
|
||||
inputId: "consent-input-1",
|
||||
headline: "Terms and Conditions",
|
||||
description: "Please read and accept the terms",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontSize",
|
||||
"inputFontWeight",
|
||||
"inputWidth",
|
||||
"inputBorderRadius",
|
||||
"inputPaddingX",
|
||||
"inputPaddingY",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "consent-1",
|
||||
inputId: "consent-input-1",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "consent-2",
|
||||
inputId: "consent-input-2",
|
||||
headline: "Terms and Conditions",
|
||||
description: "Please read and accept the terms to continue",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithConsent: Story = {
|
||||
args: {
|
||||
elementId: "consent-3",
|
||||
inputId: "consent-input-3",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
value: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "consent-4",
|
||||
inputId: "consent-input-4",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
required: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "consent-5",
|
||||
inputId: "consent-input-5",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
required: true,
|
||||
errorMessage: "You must accept the terms to continue",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "consent-6",
|
||||
inputId: "consent-input-6",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
value: true,
|
||||
disabled: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "consent-rtl",
|
||||
inputId: "consent-input-rtl",
|
||||
headline: "الشروط والأحكام",
|
||||
description: "يرجى قراءة الشروط والموافقة عليها",
|
||||
checkboxLabel: "أوافق على الشروط والأحكام",
|
||||
dir: "rtl",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithConsent: Story = {
|
||||
args: {
|
||||
elementId: "consent-rtl-checked",
|
||||
inputId: "consent-input-rtl-checked",
|
||||
headline: "الشروط والأحكام",
|
||||
checkboxLabel: "أوافق على الشروط والأحكام",
|
||||
value: true,
|
||||
dir: "rtl",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Consent
|
||||
elementId="consent-1"
|
||||
inputId="consent-input-1"
|
||||
headline="Terms and Conditions"
|
||||
description="Please read and accept the terms"
|
||||
checkboxLabel="I agree to the terms and conditions"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Consent
|
||||
elementId="consent-2"
|
||||
inputId="consent-input-2"
|
||||
headline="Privacy Policy"
|
||||
description="Please review our privacy policy"
|
||||
checkboxLabel="I agree to the privacy policy"
|
||||
value
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
92
packages/survey-ui/src/components/elements/consent.tsx
Normal file
92
packages/survey-ui/src/components/elements/consent.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
import { Checkbox } from "@/components/general/checkbox";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Props for the Consent element component
|
||||
*/
|
||||
export interface ConsentProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the consent checkbox */
|
||||
inputId: string;
|
||||
/** Label text for the consent checkbox */
|
||||
checkboxLabel: string;
|
||||
/** Whether consent is checked */
|
||||
value?: boolean;
|
||||
/** Callback function called when consent changes */
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the checkbox is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Consent({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
checkboxLabel,
|
||||
value = false,
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: Readonly<ConsentProps>): React.JSX.Element {
|
||||
const handleCheckboxChange = (checked: boolean): void => {
|
||||
if (disabled) return;
|
||||
onChange(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<label
|
||||
htmlFor={`${inputId}-checkbox`}
|
||||
className={cn(
|
||||
"bg-input-bg border-input-border text-input-text w-input px-input-x py-input-y rounded-input flex cursor-pointer items-center gap-3 border p-4 transition-colors",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 font-fontWeight focus-within:shadow-sm",
|
||||
errorMessage && "border-destructive",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
dir={dir}>
|
||||
<Checkbox
|
||||
id={`${inputId}-checkbox`}
|
||||
checked={value}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
aria-required={required}
|
||||
/>
|
||||
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
|
||||
<span
|
||||
className="font-input-weight text-input-text flex-1"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
dir={dir}>
|
||||
{checkboxLabel}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Consent };
|
||||
186
packages/survey-ui/src/components/elements/cta.stories.tsx
Normal file
186
packages/survey-ui/src/components/elements/cta.stories.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type ButtonStylingOptions,
|
||||
buttonStylingArgTypes,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { CTA, type CTAProps } from "./cta";
|
||||
|
||||
type StoryProps = CTAProps & Partial<BaseStylingOptions & ButtonStylingOptions> & Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/CTA",
|
||||
component: CTA,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A Call-to-Action (CTA) element that displays a button. Can optionally open an external URL when clicked.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
buttonLabel: {
|
||||
control: "text",
|
||||
description: "Label text for the CTA button",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonUrl: {
|
||||
control: "text",
|
||||
description: "URL to open when button is clicked (if external)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonExternal: {
|
||||
control: "boolean",
|
||||
description: "Whether the button opens an external URL",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonVariant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"],
|
||||
description: "Variant for the button. Must be 'custom' for button styling controls to work.",
|
||||
table: { category: "Button Styling (Only applicable when buttonVariant is 'custom')" },
|
||||
},
|
||||
onClick: {
|
||||
action: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
table: { category: "Events" },
|
||||
},
|
||||
...elementStylingArgTypes,
|
||||
...buttonStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "cta-1",
|
||||
inputId: "cta-input-1",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "cta-2",
|
||||
inputId: "cta-input-2",
|
||||
headline: "Ready to get started?",
|
||||
description: "Click the button below to begin your journey",
|
||||
buttonLabel: "Get Started",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ExternalButton: Story = {
|
||||
args: {
|
||||
elementId: "cta-3",
|
||||
inputId: "cta-input-3",
|
||||
headline: "Learn more about us",
|
||||
description: "Visit our website to learn more",
|
||||
buttonLabel: "Visit Website",
|
||||
buttonUrl: "https://example.com",
|
||||
buttonExternal: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "cta-4",
|
||||
inputId: "cta-input-4",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
required: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "cta-5",
|
||||
inputId: "cta-input-5",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
required: true,
|
||||
errorMessage: "Please click the button to continue",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "cta-6",
|
||||
inputId: "cta-input-6",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
disabled: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "cta-rtl",
|
||||
inputId: "cta-input-rtl",
|
||||
headline: "هل أنت مستعد للبدء؟",
|
||||
description: "انقر على الزر أدناه للبدء",
|
||||
buttonLabel: "ابدأ الآن",
|
||||
dir: "rtl",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<CTA
|
||||
elementId="cta-1"
|
||||
inputId="cta-input-1"
|
||||
headline="Ready to get started?"
|
||||
description="Click the button below to begin"
|
||||
buttonLabel="Get Started"
|
||||
onClick={() => {
|
||||
alert("clicked");
|
||||
}}
|
||||
/>
|
||||
<CTA
|
||||
elementId="cta-2"
|
||||
inputId="cta-input-2"
|
||||
headline="Learn more about us"
|
||||
description="Visit our website"
|
||||
buttonLabel="Visit Website"
|
||||
buttonUrl="https://example.com"
|
||||
buttonExternal
|
||||
onClick={() => {
|
||||
alert("clicked");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
89
packages/survey-ui/src/components/elements/cta.tsx
Normal file
89
packages/survey-ui/src/components/elements/cta.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { SquareArrowOutUpRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/general/button";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
|
||||
/**
|
||||
* Props for the CTA (Call to Action) element component
|
||||
*/
|
||||
export interface CTAProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the CTA button */
|
||||
inputId: string;
|
||||
/** Label text for the CTA button */
|
||||
buttonLabel: string;
|
||||
/** URL to open when button is clicked (if external button) */
|
||||
buttonUrl?: string;
|
||||
/** Whether the button opens an external URL */
|
||||
buttonExternal?: boolean;
|
||||
/** Callback function called when button is clicked */
|
||||
onClick: () => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the button is disabled */
|
||||
disabled?: boolean;
|
||||
/** Variant for the button */
|
||||
buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
|
||||
}
|
||||
|
||||
function CTA({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
buttonLabel,
|
||||
buttonUrl,
|
||||
buttonExternal = false,
|
||||
onClick,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
buttonVariant = "default",
|
||||
}: Readonly<CTAProps>): React.JSX.Element {
|
||||
const handleButtonClick = (): void => {
|
||||
if (disabled) return;
|
||||
onClick();
|
||||
|
||||
if (buttonExternal && buttonUrl) {
|
||||
window.open(buttonUrl, "_blank")?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<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 ? <SquareArrowOutUpRightIcon className="size-4" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CTA };
|
||||
315
packages/survey-ui/src/components/elements/date.stories.tsx
Normal file
315
packages/survey-ui/src/components/elements/date.stories.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { DateElement, type DateElementProps } from "./date";
|
||||
|
||||
type StoryProps = DateElementProps &
|
||||
Partial<BaseStylingOptions & Pick<InputLayoutStylingOptions, "inputBorderRadius">> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Date",
|
||||
component: DateElement,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete date element that combines headline, description, and a date input. Supports date range constraints, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Current date value in ISO format (YYYY-MM-DD)",
|
||||
table: { category: "State" },
|
||||
},
|
||||
minDate: {
|
||||
control: "text",
|
||||
description: "Minimum date allowed (ISO format: YYYY-MM-DD)",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
maxDate: {
|
||||
control: "text",
|
||||
description: "Maximum date allowed (ISO format: YYYY-MM-DD)",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
locale: {
|
||||
control: { type: "select" },
|
||||
options: [
|
||||
"en",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"ja",
|
||||
"pt",
|
||||
"pt-BR",
|
||||
"ro",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"nl",
|
||||
"ar",
|
||||
"it",
|
||||
"ru",
|
||||
"uz",
|
||||
"hi",
|
||||
],
|
||||
description: "Locale code for date formatting (survey language codes: 'en', 'de', 'ar', etc.)",
|
||||
table: { category: "Localization" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(DateElement),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select a date",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
inputBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "When would you like to schedule the appointment?",
|
||||
description: "Please select a date for your appointment",
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select your date of birth",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
value: "1990-01-15",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDateRange: Story = {
|
||||
args: {
|
||||
headline: "Select a date for your event",
|
||||
description: "Please choose a date between today and next year",
|
||||
minDate: new Date().toISOString().split("T")[0],
|
||||
maxDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select your date of birth",
|
||||
errorMessage: "Please select a valid date",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This date field is disabled",
|
||||
description: "You cannot change the date",
|
||||
value: "2024-01-15",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const PastDatesOnly: Story = {
|
||||
args: {
|
||||
headline: "When did you start your current job?",
|
||||
description: "Select a date in the past",
|
||||
maxDate: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const FutureDatesOnly: Story = {
|
||||
args: {
|
||||
headline: "When would you like to schedule the meeting?",
|
||||
description: "Select a date in the future",
|
||||
minDate: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو تاريخ ميلادك؟",
|
||||
description: "يرجى اختيار تاريخ",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithValue: Story = {
|
||||
args: {
|
||||
headline: "ما هو تاريخ ميلادك؟",
|
||||
description: "يرجى اختيار تاريخ",
|
||||
value: "1990-01-15",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<DateElement
|
||||
elementId="date-1"
|
||||
inputId="date-1-input"
|
||||
headline="What is your date of birth?"
|
||||
description="Please select your date of birth"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<DateElement
|
||||
elementId="date-2"
|
||||
inputId="date-2-input"
|
||||
headline="When would you like to schedule the appointment?"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLocale: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Date picker with locale-specific formatting",
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
export const LocaleExamples: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">English (en)</h3>
|
||||
<DateElement
|
||||
elementId="date-en"
|
||||
inputId="date-en-input"
|
||||
headline="What is your date of birth?"
|
||||
locale="en"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">German (de)</h3>
|
||||
<DateElement
|
||||
elementId="date-de"
|
||||
inputId="date-de-input"
|
||||
headline="Was ist Ihr Geburtsdatum?"
|
||||
locale="de"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">French (fr)</h3>
|
||||
<DateElement
|
||||
elementId="date-fr"
|
||||
inputId="date-fr-input"
|
||||
headline="Quelle est votre date de naissance ?"
|
||||
locale="fr"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Spanish (es)</h3>
|
||||
<DateElement
|
||||
elementId="date-es"
|
||||
inputId="date-es-input"
|
||||
headline="¿Cuál es su fecha de nacimiento?"
|
||||
locale="es"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Japanese (ja)</h3>
|
||||
<DateElement
|
||||
elementId="date-ja"
|
||||
inputId="date-ja-input"
|
||||
headline="生年月日を教えてください"
|
||||
locale="ja"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Arabic (ar)</h3>
|
||||
<DateElement
|
||||
elementId="date-ar"
|
||||
inputId="date-ar-input"
|
||||
headline="ما هو تاريخ ميلادك؟"
|
||||
locale="ar"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Russian (ru)</h3>
|
||||
<DateElement
|
||||
elementId="date-ru"
|
||||
inputId="date-ru-input"
|
||||
headline="Какова ваша дата рождения?"
|
||||
locale="ru"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Chinese Simplified (zh-Hans)</h3>
|
||||
<DateElement
|
||||
elementId="date-zh"
|
||||
inputId="date-zh-input"
|
||||
headline="您的出生日期是什么?"
|
||||
locale="zh-Hans"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
148
packages/survey-ui/src/components/elements/date.tsx
Normal file
148
packages/survey-ui/src/components/elements/date.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as React from "react";
|
||||
import { Calendar } from "@/components/general/calendar";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { getDateFnsLocale } from "@/lib/locale";
|
||||
|
||||
interface DateElementProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the date input */
|
||||
inputId: string;
|
||||
/** Current date value in ISO format (YYYY-MM-DD) */
|
||||
value?: string;
|
||||
/** Callback function called when the date value changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Minimum date allowed (ISO format: YYYY-MM-DD) */
|
||||
minDate?: string;
|
||||
/** Maximum date allowed (ISO format: YYYY-MM-DD) */
|
||||
maxDate?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the date input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
function DateElement({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
minDate,
|
||||
maxDate,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
locale = "en-US",
|
||||
}: Readonly<DateElementProps>): React.JSX.Element {
|
||||
// Initialize date from value string, parsing as local time to avoid timezone issues
|
||||
const [date, setDate] = React.useState<Date | undefined>(() => {
|
||||
if (!value) return undefined;
|
||||
// Parse YYYY-MM-DD format as local date (not UTC)
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
});
|
||||
|
||||
// Sync date state when value prop changes
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
// Parse YYYY-MM-DD format as local date (not UTC)
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
const newDate = new Date(year, month - 1, day);
|
||||
setDate((prevDate) => {
|
||||
// Only update if the date actually changed to avoid unnecessary re-renders
|
||||
if (!prevDate || newDate.getTime() !== prevDate.getTime()) {
|
||||
return newDate;
|
||||
}
|
||||
return prevDate;
|
||||
});
|
||||
} else {
|
||||
setDate(undefined);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Convert Date to ISO string (YYYY-MM-DD) when date changes
|
||||
const handleDateSelect = (selectedDate: Date | undefined): void => {
|
||||
setDate(selectedDate);
|
||||
if (selectedDate) {
|
||||
// Convert to ISO format (YYYY-MM-DD) using local time to avoid timezone issues
|
||||
const year = String(selectedDate.getFullYear());
|
||||
const month = String(selectedDate.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(selectedDate.getDate()).padStart(2, "0");
|
||||
const isoString = `${year}-${month}-${day}`;
|
||||
onChange(isoString);
|
||||
} else {
|
||||
onChange("");
|
||||
}
|
||||
};
|
||||
|
||||
// Convert minDate/maxDate strings to Date objects
|
||||
const minDateObj = minDate ? new Date(minDate) : undefined;
|
||||
const maxDateObj = maxDate ? new Date(maxDate) : undefined;
|
||||
|
||||
// Create disabled function for date restrictions
|
||||
const isDateDisabled = React.useCallback(
|
||||
(dateToCheck: Date): boolean => {
|
||||
if (disabled) return true;
|
||||
if (minDateObj) {
|
||||
const minAtMidnight = new Date(minDateObj.getFullYear(), minDateObj.getMonth(), minDateObj.getDate());
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
if (checkAtMidnight < minAtMidnight) return true;
|
||||
}
|
||||
if (maxDateObj) {
|
||||
const maxAtMidnight = new Date(maxDateObj.getFullYear(), maxDateObj.getMonth(), maxDateObj.getDate());
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
if (checkAtMidnight > maxAtMidnight) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[disabled, minDateObj, maxDateObj]
|
||||
);
|
||||
|
||||
// Get locale for date formatting
|
||||
const dateLocale = React.useMemo(() => {
|
||||
return locale ? getDateFnsLocale(locale) : undefined;
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* 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}
|
||||
locale={dateLocale}
|
||||
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DateElement };
|
||||
export type { DateElementProps };
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
pickArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { FileUpload, type FileUploadProps, type UploadedFile } from "./file-upload";
|
||||
|
||||
type StoryProps = FileUploadProps &
|
||||
Partial<BaseStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/FileUpload",
|
||||
component: FileUpload,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete file upload element that combines headline, description, and a file upload area with drag-and-drop support. Supports file type restrictions, size limits, multiple files, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Array of uploaded files",
|
||||
table: { category: "State" },
|
||||
},
|
||||
allowMultiple: {
|
||||
control: "boolean",
|
||||
description: "Whether multiple files are allowed",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
maxSizeInMB: {
|
||||
control: "number",
|
||||
description: "Maximum file size in MB",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
allowedFileExtensions: {
|
||||
control: "object",
|
||||
description: "Allowed file extensions (e.g., ['.pdf', '.jpg'])",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
...elementStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontSize",
|
||||
"inputFontWeight",
|
||||
"inputWidth",
|
||||
"inputHeight",
|
||||
"inputBorderRadius",
|
||||
"inputPaddingX",
|
||||
"inputPaddingY",
|
||||
]),
|
||||
},
|
||||
render: function Render(args: StoryProps) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<FileUpload
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Upload your file",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "Upload your resume",
|
||||
description: "Please upload your resume in PDF format",
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleFile: Story = {
|
||||
args: {
|
||||
headline: "Upload a single file",
|
||||
description: "Select one file to upload",
|
||||
allowMultiple: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleFiles: Story = {
|
||||
args: {
|
||||
headline: "Upload multiple files",
|
||||
description: "You can upload multiple files at once",
|
||||
allowMultiple: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFileTypeRestrictions: Story = {
|
||||
args: {
|
||||
headline: "Upload an image",
|
||||
description: "Please upload an image file",
|
||||
allowedFileExtensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSizeLimit: Story = {
|
||||
args: {
|
||||
headline: "Upload a document",
|
||||
description: "Maximum file size: 5MB",
|
||||
maxSizeInMB: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRestrictions: Story = {
|
||||
args: {
|
||||
headline: "Upload a PDF document",
|
||||
description: "PDF files only, maximum 10MB",
|
||||
allowedFileExtensions: [".pdf"],
|
||||
maxSizeInMB: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Upload required file",
|
||||
description: "Please upload a file",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithUploadedFiles: Story = {
|
||||
args: {
|
||||
headline: "Upload your files",
|
||||
description: "Files you've uploaded",
|
||||
allowMultiple: true,
|
||||
value: [
|
||||
{
|
||||
name: "document.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 500, // 500 KB
|
||||
},
|
||||
{
|
||||
name: "image.jpg",
|
||||
url: "data:image/jpeg;base64,...",
|
||||
size: 1024 * 1024 * 2, // 2 MB
|
||||
},
|
||||
] as UploadedFile[],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Upload your file",
|
||||
description: "Please upload a file",
|
||||
errorMessage: "Please upload at least one file",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This upload is disabled",
|
||||
description: "You cannot upload files",
|
||||
value: [
|
||||
{
|
||||
name: "existing-file.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 300,
|
||||
},
|
||||
] as UploadedFile[],
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "قم بتحميل ملفك",
|
||||
description: "يرجى اختيار ملف للتحميل",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithFiles: Story = {
|
||||
args: {
|
||||
headline: "قم بتحميل ملفاتك",
|
||||
description: "الملفات التي قمت بتحميلها",
|
||||
allowMultiple: true,
|
||||
value: [
|
||||
{
|
||||
name: "ملف.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 500,
|
||||
},
|
||||
] as UploadedFile[],
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<FileUpload
|
||||
elementId="file-1"
|
||||
inputId="file-1-input"
|
||||
headline="Upload your resume"
|
||||
description="PDF format only"
|
||||
allowedFileExtensions={[".pdf"]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<FileUpload
|
||||
elementId="file-2"
|
||||
inputId="file-2-input"
|
||||
headline="Upload multiple images"
|
||||
description="You can upload multiple images"
|
||||
allowMultiple
|
||||
allowedFileExtensions={[".jpg", ".png", ".gif"]}
|
||||
maxSizeInMB={5}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
336
packages/survey-ui/src/components/elements/file-upload.tsx
Normal file
336
packages/survey-ui/src/components/elements/file-upload.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { Upload, UploadIcon, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Uploaded file information
|
||||
*/
|
||||
export interface UploadedFile {
|
||||
/** File name */
|
||||
name: string;
|
||||
/** File URL or data URL */
|
||||
url: string;
|
||||
/** File size in bytes */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface FileUploadProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the file input */
|
||||
inputId: string;
|
||||
/** Currently uploaded files */
|
||||
value?: UploadedFile[];
|
||||
/** Callback function called when files change */
|
||||
onChange: (files: UploadedFile[]) => void;
|
||||
/** Callback function called when files are selected (before validation) */
|
||||
onFileSelect?: (files: FileList) => void;
|
||||
/** Whether multiple files are allowed */
|
||||
allowMultiple?: boolean;
|
||||
/** Allowed file extensions (e.g., ['.pdf', '.jpg', '.png']) */
|
||||
allowedFileExtensions?: string[];
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Whether the component is in uploading state */
|
||||
isUploading?: boolean;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the file input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
/** Alt text for the image */
|
||||
imageAltText?: string;
|
||||
/** Placeholder text for the file upload */
|
||||
placeholderText?: string;
|
||||
}
|
||||
|
||||
interface UploadedFileItemProps {
|
||||
file: UploadedFile;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onDelete: (index: number, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function UploadedFileItem({
|
||||
file,
|
||||
index,
|
||||
disabled,
|
||||
onDelete,
|
||||
}: Readonly<UploadedFileItemProps>): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-input-border bg-accent-selected text-input-text rounded-input relative m-1 rounded-md border"
|
||||
)}>
|
||||
<div className="absolute top-0 right-0 m-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
onDelete(index, e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-5 w-5 cursor-pointer items-center justify-center rounded-md",
|
||||
"bg-background hover:bg-accent",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label={`Delete ${file.name}`}>
|
||||
<X className="text-foreground h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<UploadIcon />
|
||||
<p
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
className="mt-1 w-full overflow-hidden px-2 text-center overflow-ellipsis whitespace-nowrap text-[var(--foreground)]"
|
||||
title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UploadedFilesListProps {
|
||||
files: UploadedFile[];
|
||||
disabled: boolean;
|
||||
onDelete: (index: number, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function UploadedFilesList({
|
||||
files,
|
||||
disabled,
|
||||
onDelete,
|
||||
}: Readonly<UploadedFilesListProps>): React.JSX.Element | null {
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 p-2">
|
||||
{files.map((file, index) => (
|
||||
<UploadedFileItem
|
||||
key={`${file.name}-${file.url}`}
|
||||
file={file}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UploadAreaProps {
|
||||
inputId: string;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
placeholderText: string;
|
||||
allowMultiple: boolean;
|
||||
acceptAttribute?: string;
|
||||
required: boolean;
|
||||
disabled: boolean;
|
||||
dir: "ltr" | "rtl" | "auto";
|
||||
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||
onDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||
showUploader: boolean;
|
||||
}
|
||||
|
||||
function UploadArea({
|
||||
inputId,
|
||||
fileInputRef,
|
||||
placeholderText,
|
||||
allowMultiple,
|
||||
acceptAttribute,
|
||||
required,
|
||||
disabled,
|
||||
dir,
|
||||
onFileChange,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
showUploader,
|
||||
}: Readonly<UploadAreaProps>): React.JSX.Element | null {
|
||||
if (!showUploader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
className={cn("block w-full", disabled && "cursor-not-allowed")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center justify-center py-6",
|
||||
"hover:cursor-pointer",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Upload files by clicking or dragging them here">
|
||||
<Upload className="text-input-text h-6" aria-hidden="true" />
|
||||
<span
|
||||
className="text-input-text font-input-weight m-2"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
id={`${inputId}-label`}>
|
||||
{placeholderText}
|
||||
</span>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id={inputId}
|
||||
className="hidden"
|
||||
multiple={allowMultiple}
|
||||
accept={acceptAttribute}
|
||||
onChange={onFileChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
dir={dir}
|
||||
aria-label="File upload"
|
||||
aria-describedby={`${inputId}-label`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FileUpload({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
value = [],
|
||||
onChange,
|
||||
onFileSelect,
|
||||
allowMultiple = false,
|
||||
allowedFileExtensions,
|
||||
required = false,
|
||||
errorMessage,
|
||||
isUploading = false,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
imageAltText,
|
||||
placeholderText = "Click or drag to upload files",
|
||||
}: Readonly<FileUploadProps>): React.JSX.Element {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Ensure value is always an array
|
||||
const uploadedFiles = Array.isArray(value) ? value : [];
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (!e.target.files || disabled) return;
|
||||
if (onFileSelect) {
|
||||
onFileSelect(e.target.files);
|
||||
}
|
||||
// Reset input to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onFileSelect && e.dataTransfer.files.length > 0) {
|
||||
onFileSelect(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = (index: number, e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
const updatedFiles = [...uploadedFiles];
|
||||
updatedFiles.splice(index, 1);
|
||||
onChange(updatedFiles);
|
||||
};
|
||||
|
||||
// Build accept attribute from allowed extensions
|
||||
const acceptAttribute = allowedFileExtensions
|
||||
?.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
|
||||
// Show uploader if uploading, or if multiple files allowed, or if no files uploaded yet
|
||||
const showUploader = isUploading || allowMultiple || uploadedFiles.length === 0;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
imageAltText={imageAltText}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-input px-input-x py-input-y rounded-input relative flex flex-col items-center justify-center border-2 border-dashed transition-colors",
|
||||
errorMessage ? "border-destructive" : "border-input-border bg-accent",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}>
|
||||
<UploadedFilesList files={uploadedFiles} disabled={disabled} onDelete={handleDeleteFile} />
|
||||
|
||||
<div className="w-full">
|
||||
{isUploading ? (
|
||||
<div className="flex animate-pulse items-center justify-center rounded-lg py-4">
|
||||
<p
|
||||
className="text-muted-foreground font-medium"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}>
|
||||
Uploading...
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<UploadArea
|
||||
inputId={inputId}
|
||||
fileInputRef={fileInputRef}
|
||||
placeholderText={placeholderText}
|
||||
allowMultiple={allowMultiple}
|
||||
acceptAttribute={acceptAttribute}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
onFileChange={handleFileChange}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
showUploader={showUploader}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FileUpload };
|
||||
export type { FileUploadProps };
|
||||
@@ -0,0 +1,362 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
} from "../../lib/story-helpers";
|
||||
import { FormField, type FormFieldConfig, type FormFieldProps } from "./form-field";
|
||||
|
||||
type StoryProps = FormFieldProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & InputLayoutStylingOptions & { inputShadow: string }> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/FormField",
|
||||
component: FormField,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A flexible form field element that can display multiple input fields with different configurations. Replaces Contact Info and Address elements.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
fields: {
|
||||
control: "object",
|
||||
description: "Array of form field configurations",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Current values as a record mapping field IDs to their values",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(FormField),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
// Contact Info fields preset
|
||||
const contactInfoFields: FormFieldConfig[] = [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: true, show: true },
|
||||
{ id: "phone", label: "Phone", placeholder: "Phone", type: "tel", required: true, show: true },
|
||||
{ id: "company", label: "Company", placeholder: "Company", required: true, show: true },
|
||||
];
|
||||
|
||||
// Address fields preset
|
||||
const addressFields: FormFieldConfig[] = [
|
||||
{ id: "addressLine1", label: "Address Line 1", placeholder: "Address Line 1", required: true, show: true },
|
||||
{ id: "addressLine2", label: "Address Line 2", placeholder: "Address Line 2", required: true, show: true },
|
||||
{ id: "city", label: "City", placeholder: "City", required: true, show: true },
|
||||
{ id: "state", label: "State", placeholder: "State", required: true, show: true },
|
||||
{ id: "zip", label: "Zip", placeholder: "Zip", required: true, show: true },
|
||||
{ id: "country", label: "Country", placeholder: "Country", required: true, show: true },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "form-field-1",
|
||||
headline: "Please provide your contact information",
|
||||
description: "We'll use this to contact you",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
argTypes: {
|
||||
elementHeadlineFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
labelFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelColor: {
|
||||
control: "color",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
inputWidth: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputHeight: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPaddingX: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPaddingY: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputShadow: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
brandColor: {
|
||||
control: "color",
|
||||
table: { category: "Survey Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "form-field-1",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "form-field-2",
|
||||
headline: "Please provide your contact information",
|
||||
description: "We'll use this to contact you about your inquiry",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const ContactInfo: Story = {
|
||||
args: {
|
||||
elementId: "form-field-contact",
|
||||
headline: "Contact Information",
|
||||
description: "Please provide your contact details",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const Address: Story = {
|
||||
args: {
|
||||
elementId: "form-field-address",
|
||||
headline: "Shipping Address",
|
||||
description: "Please provide your shipping address",
|
||||
fields: addressFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "form-field-3",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValues: Story = {
|
||||
args: {
|
||||
elementId: "form-field-4",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
value: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john.doe@example.com",
|
||||
phone: "+1234567890",
|
||||
company: "Acme Inc.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "form-field-5",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
required: true,
|
||||
errorMessage: "Please fill in all required fields",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "form-field-6",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
disabled: true,
|
||||
value: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PartialFields: Story = {
|
||||
args: {
|
||||
elementId: "form-field-7",
|
||||
headline: "Basic Information",
|
||||
fields: [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true },
|
||||
{
|
||||
id: "phone",
|
||||
label: "Phone",
|
||||
placeholder: "Phone (optional)",
|
||||
type: "tel",
|
||||
required: false,
|
||||
show: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const OptionalFields: Story = {
|
||||
args: {
|
||||
elementId: "form-field-8",
|
||||
headline: "Optional Information",
|
||||
fields: [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: false, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: false, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "form-field-rtl",
|
||||
headline: "يرجى تقديم معلومات الاتصال الخاصة بك",
|
||||
description: "سنستخدم هذا للاتصال بك",
|
||||
dir: "rtl",
|
||||
fields: [
|
||||
{ id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true },
|
||||
{ id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true },
|
||||
{
|
||||
id: "email",
|
||||
label: "البريد الإلكتروني",
|
||||
placeholder: "البريد الإلكتروني",
|
||||
type: "email",
|
||||
required: true,
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithValues: Story = {
|
||||
args: {
|
||||
elementId: "form-field-rtl-values",
|
||||
dir: "rtl",
|
||||
headline: "يرجى تقديم معلومات الاتصال الخاصة بك",
|
||||
fields: [
|
||||
{ id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true },
|
||||
{ id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true },
|
||||
{
|
||||
id: "email",
|
||||
label: "البريد الإلكتروني",
|
||||
placeholder: "البريد الإلكتروني",
|
||||
type: "email",
|
||||
required: true,
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
value: {
|
||||
firstName: "أحمد",
|
||||
lastName: "محمد",
|
||||
email: "ahmed@example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<FormField
|
||||
elementId="form-field-1"
|
||||
headline="Contact Information"
|
||||
description="Please provide your contact details"
|
||||
fields={contactInfoFields}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<FormField
|
||||
elementId="form-field-2"
|
||||
headline="Shipping Address"
|
||||
description="Where should we ship your order?"
|
||||
fields={addressFields}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
140
packages/survey-ui/src/components/elements/form-field.tsx
Normal file
140
packages/survey-ui/src/components/elements/form-field.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { Label } from "@/components/general/label";
|
||||
|
||||
/**
|
||||
* Form field configuration
|
||||
*/
|
||||
export interface FormFieldConfig {
|
||||
/** Unique identifier for the field */
|
||||
id: string;
|
||||
/** Label text for the field */
|
||||
label: string;
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string;
|
||||
/** Input type (text, email, tel, number, url, etc.) */
|
||||
type?: "text" | "email" | "tel" | "number" | "url";
|
||||
/** Whether this field is required */
|
||||
required?: boolean;
|
||||
/** Whether this field should be shown */
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Array of form field configurations */
|
||||
fields: FormFieldConfig[];
|
||||
/** Current values as a record mapping field IDs to their values */
|
||||
value?: Record<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,
|
||||
}: Readonly<FormFieldProps>): React.JSX.Element {
|
||||
// Ensure value is always an object
|
||||
const currentValues = React.useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- value can be undefined
|
||||
return value ?? {};
|
||||
}, [value]);
|
||||
|
||||
// Determine if a field is required
|
||||
const isFieldRequired = (field: FormFieldConfig): boolean => {
|
||||
if (field.required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If all fields are optional and the form is required, then fields should be required
|
||||
const visibleFields = fields.filter((f) => f.show !== false);
|
||||
const allOptional = visibleFields.every((f) => !f.required);
|
||||
if (allOptional && required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Handle field value change
|
||||
const handleFieldChange = (fieldId: string, fieldValue: string): void => {
|
||||
onChange({
|
||||
...currentValues,
|
||||
[fieldId]: fieldValue,
|
||||
});
|
||||
};
|
||||
|
||||
// Get visible fields
|
||||
const visibleFields = fields.filter((field) => field.show !== false);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} />
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative space-y-3">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{visibleFields.map((field) => {
|
||||
const fieldRequired = isFieldRequired(field);
|
||||
const fieldValue = currentValues[field.id] ?? "";
|
||||
const fieldInputId = `${elementId}-${field.id}`;
|
||||
|
||||
// Determine input type
|
||||
let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text";
|
||||
if (field.id === "email" && !field.type) {
|
||||
inputType = "email";
|
||||
} else if (field.id === "phone" && !field.type) {
|
||||
inputType = "tel";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={fieldInputId} variant="default">
|
||||
{fieldRequired ? `${field.label}*` : field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldInputId}
|
||||
type={inputType}
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
handleFieldChange(field.id, e.target.value);
|
||||
}}
|
||||
required={fieldRequired}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormField };
|
||||
export type { FormFieldProps };
|
||||
307
packages/survey-ui/src/components/elements/matrix.stories.tsx
Normal file
307
packages/survey-ui/src/components/elements/matrix.stories.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Matrix, type MatrixOption, type MatrixProps } from "./matrix";
|
||||
|
||||
type StoryProps = MatrixProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Matrix",
|
||||
component: Matrix,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete matrix element that combines headline, description, and a table with rows and columns. Each row can have one selected column value. Supports validation and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
rows: {
|
||||
control: "object",
|
||||
description: "Array of row options (left side)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
columns: {
|
||||
control: "object",
|
||||
description: "Array of column options (top header)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Record mapping row ID to column ID",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Matrix),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
const defaultRows: MatrixOption[] = [
|
||||
{ id: "row-1", label: "Row 1" },
|
||||
{ id: "row-2", label: "Row 2" },
|
||||
{ id: "row-3", label: "Row 3" },
|
||||
];
|
||||
|
||||
const defaultColumns: MatrixOption[] = [
|
||||
{ id: "col-1", label: "Column 1" },
|
||||
{ id: "col-2", label: "Column 2" },
|
||||
{ id: "col-3", label: "Column 3" },
|
||||
{ id: "col-4", label: "Column 4" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Rate each item",
|
||||
description: "Select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, ["inputBgColor", "inputBorderColor"]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "matrix-default",
|
||||
inputId: "matrix-default-input",
|
||||
headline: "Rate each item",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "matrix-with-description",
|
||||
inputId: "matrix-with-description-input",
|
||||
headline: "How satisfied are you with each feature?",
|
||||
description: "Please rate each feature on a scale from 1 to 5",
|
||||
rows: [
|
||||
{ id: "feature-1", label: "Feature 1" },
|
||||
{ id: "feature-2", label: "Feature 2" },
|
||||
{ id: "feature-3", label: "Feature 3" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "matrix-required",
|
||||
inputId: "matrix-required-input",
|
||||
headline: "Rate each item",
|
||||
description: "Please select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelections: Story = {
|
||||
args: {
|
||||
elementId: "matrix-selections",
|
||||
inputId: "matrix-selections-input",
|
||||
headline: "Rate each item",
|
||||
description: "Select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
value: {
|
||||
"row-1": "col-2",
|
||||
"row-2": "col-3",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "matrix-error",
|
||||
inputId: "matrix-error-input",
|
||||
headline: "Rate each item",
|
||||
description: "Please select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
errorMessage: "Please complete all rows",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "matrix-disabled",
|
||||
inputId: "matrix-disabled-input",
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
value: {
|
||||
"row-1": "col-2",
|
||||
"row-2": "col-3",
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RatingScale: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rating-scale",
|
||||
inputId: "matrix-rating-scale-input",
|
||||
headline: "Rate your experience",
|
||||
description: "How would you rate each aspect?",
|
||||
rows: [
|
||||
{ id: "quality", label: "Quality" },
|
||||
{ id: "service", label: "Service" },
|
||||
{ id: "value", label: "Value for Money" },
|
||||
{ id: "support", label: "Customer Support" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "poor", label: "Poor" },
|
||||
{ id: "fair", label: "Fair" },
|
||||
{ id: "good", label: "Good" },
|
||||
{ id: "very-good", label: "Very Good" },
|
||||
{ id: "excellent", label: "Excellent" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NumericScale: Story = {
|
||||
args: {
|
||||
elementId: "matrix-numeric-scale",
|
||||
inputId: "matrix-numeric-scale-input",
|
||||
headline: "Rate from 0 to 10",
|
||||
description: "Select a number for each item",
|
||||
rows: [
|
||||
{ id: "item-1", label: "Item 1" },
|
||||
{ id: "item-2", label: "Item 2" },
|
||||
{ id: "item-3", label: "Item 3" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "0", label: "0" },
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
{ id: "6", label: "6" },
|
||||
{ id: "7", label: "7" },
|
||||
{ id: "8", label: "8" },
|
||||
{ id: "9", label: "9" },
|
||||
{ id: "10", label: "10" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rtl",
|
||||
inputId: "matrix-rtl-input",
|
||||
headline: "قيم كل عنصر",
|
||||
description: "اختر قيمة لكل صف",
|
||||
dir: "rtl",
|
||||
rows: [
|
||||
{ id: "row-1", label: "الصف الأول" },
|
||||
{ id: "row-2", label: "الصف الثاني" },
|
||||
{ id: "row-3", label: "الصف الثالث" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "col-1", label: "عمود 1" },
|
||||
{ id: "col-2", label: "عمود 2" },
|
||||
{ id: "col-3", label: "عمود 3" },
|
||||
{ id: "col-4", label: "عمود 4" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelections: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rtl-selections",
|
||||
inputId: "matrix-rtl-selections-input",
|
||||
dir: "rtl",
|
||||
headline: "قيم كل عنصر",
|
||||
description: "يرجى اختيار قيمة لكل صف",
|
||||
rows: [
|
||||
{ id: "quality", label: "الجودة" },
|
||||
{ id: "service", label: "الخدمة" },
|
||||
{ id: "value", label: "القيمة" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "poor", label: "ضعيف" },
|
||||
{ id: "fair", label: "مقبول" },
|
||||
{ id: "good", label: "جيد" },
|
||||
{ id: "very-good", label: "جيد جداً" },
|
||||
{ id: "excellent", label: "ممتاز" },
|
||||
],
|
||||
value: {
|
||||
quality: "good",
|
||||
service: "very-good",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Matrix
|
||||
elementId="matrix-1"
|
||||
inputId="matrix-1-input"
|
||||
headline="Rate each item"
|
||||
description="Select a value for each row"
|
||||
rows={defaultRows}
|
||||
columns={defaultColumns}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Matrix
|
||||
elementId="matrix-2"
|
||||
inputId="matrix-2-input"
|
||||
headline="How satisfied are you?"
|
||||
rows={[
|
||||
{ id: "feature-1", label: "Feature 1" },
|
||||
{ id: "feature-2", label: "Feature 2" },
|
||||
]}
|
||||
columns={[
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
]}
|
||||
value={{
|
||||
"feature-1": "4",
|
||||
"feature-2": "5",
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
166
packages/survey-ui/src/components/elements/matrix.tsx
Normal file
166
packages/survey-ui/src/components/elements/matrix.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { RadioGroupItem } from "@/components/general/radio-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Option for matrix element rows and columns
|
||||
*/
|
||||
export interface MatrixOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MatrixProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the matrix group */
|
||||
inputId: string;
|
||||
/** Array of row options (left side) */
|
||||
rows: MatrixOption[];
|
||||
/** Array of column options (top header) */
|
||||
columns: MatrixOption[];
|
||||
/** Currently selected values: Record mapping row ID to column ID */
|
||||
value?: Record<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,
|
||||
}: Readonly<MatrixProps>): React.JSX.Element {
|
||||
// Ensure value is always an object (value already has default of {})
|
||||
const selectedValues = value;
|
||||
|
||||
// Check which rows have errors (no selection when required)
|
||||
const hasError = Boolean(errorMessage);
|
||||
const rowsWithErrors = hasError && required ? rows.filter((row) => !selectedValues[row.id]) : [];
|
||||
|
||||
const handleRowChange = (rowId: string, columnId: string): void => {
|
||||
// Toggle: if same column is selected, deselect it
|
||||
if (selectedValues[rowId] === columnId) {
|
||||
// Create new object without the rowId property
|
||||
const { [rowId]: _, ...rest } = selectedValues;
|
||||
onChange(rest);
|
||||
} else {
|
||||
onChange({ ...selectedValues, [rowId]: columnId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{/* Table container with overflow for mobile */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
{/* Column headers */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 text-left" />
|
||||
{columns.map((column) => (
|
||||
<th key={column.id} className="p-2 text-center font-normal">
|
||||
<Label className="justify-center">{column.label}</Label>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
{/* Rows */}
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const rowGroupId = `${inputId}-row-${row.id}`;
|
||||
const selectedColumnId = selectedValues[row.id];
|
||||
const rowHasError = rowsWithErrors.includes(row);
|
||||
const baseBgColor = index % 2 === 0 ? "bg-input-bg" : "bg-transparent";
|
||||
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
key={row.id}
|
||||
asChild
|
||||
value={selectedColumnId}
|
||||
onValueChange={(newColumnId) => {
|
||||
handleRowChange(row.id, newColumnId);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
aria-invalid={Boolean(errorMessage)}>
|
||||
<tr className={cn("relative", baseBgColor, rowHasError ? "bg-destructive-muted" : "")}>
|
||||
{/* Row label */}
|
||||
<th scope="row" className={cn("p-2 align-middle", !rowHasError && "rounded-l-input")}>
|
||||
<div className="flex flex-col gap-0 leading-none">
|
||||
<Label>{row.label}</Label>
|
||||
{rowHasError ? (
|
||||
<span className="text-destructive text-xs font-normal">Select one option</span>
|
||||
) : null}
|
||||
</div>
|
||||
</th>
|
||||
{/* Column options for this row */}
|
||||
{columns.map((column, colIndex) => {
|
||||
const cellId = `${rowGroupId}-${column.id}`;
|
||||
const isLastColumn = colIndex === columns.length - 1;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.id}
|
||||
className={cn(
|
||||
"p-2 text-center align-middle",
|
||||
isLastColumn && !rowHasError && "rounded-r-input"
|
||||
)}>
|
||||
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
|
||||
<RadioGroupItem
|
||||
value={column.id}
|
||||
id={cellId}
|
||||
disabled={disabled}
|
||||
aria-label={`${row.label}-${column.label}`}
|
||||
/>
|
||||
</Label>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</RadioGroupPrimitive.Root>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Matrix };
|
||||
export type { MatrixProps };
|
||||
@@ -0,0 +1,353 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type CheckboxInputStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
type OptionStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
optionStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { MultiSelect, type MultiSelectOption, type MultiSelectProps } from "./multi-select";
|
||||
|
||||
type StoryProps = MultiSelectProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & OptionStylingOptions & CheckboxInputStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/MultiSelect",
|
||||
component: MultiSelect,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete multi-select element that combines headline, description, and checkbox options. Supports multiple selections, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of options to choose from",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Array of selected option IDs",
|
||||
table: { category: "State" },
|
||||
},
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["list", "dropdown"],
|
||||
description: "Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for dropdown button when no options are selected",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: function Render(args: StoryProps) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
const [otherValue, setOtherValue] = useState(args.otherValue);
|
||||
const handleOtherValueChange = (v: string) => {
|
||||
setOtherValue(v);
|
||||
args.onOtherValueChange?.(v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={handleOtherValueChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
const defaultOptions: MultiSelectOption[] = [
|
||||
{ id: "option-1", label: "Option 1" },
|
||||
{ id: "option-2", label: "Option 2" },
|
||||
{ id: "option-3", label: "Option 3" },
|
||||
{ id: "option-4", label: "Option 4" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...optionStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "What programming languages do you know?",
|
||||
description: "Select all programming languages you're familiar with",
|
||||
options: [
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
{ id: "java", label: "Java" },
|
||||
{ id: "go", label: "Go" },
|
||||
{ id: "rust", label: "Rust" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Select your interests",
|
||||
description: "Please select at least one option",
|
||||
options: [
|
||||
{ id: "tech", label: "Technology" },
|
||||
{ id: "design", label: "Design" },
|
||||
{ id: "marketing", label: "Marketing" },
|
||||
{ id: "sales", label: "Sales" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelections: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
value: ["option-1", "option-3"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Select your preferences",
|
||||
description: "Please select at least one option",
|
||||
options: [
|
||||
{ id: "email", label: "Email notifications" },
|
||||
{ id: "sms", label: "SMS notifications" },
|
||||
{ id: "push", label: "Push notifications" },
|
||||
],
|
||||
errorMessage: "Please select at least one option",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
options: defaultOptions,
|
||||
value: ["option-2"],
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
headline: "Select all that apply",
|
||||
description: "Choose as many as you like",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
{ id: "3", label: "Option 3" },
|
||||
{ id: "4", label: "Option 4" },
|
||||
{ id: "5", label: "Option 5" },
|
||||
{ id: "6", label: "Option 6" },
|
||||
{ id: "7", label: "Option 7" },
|
||||
{ id: "8", label: "Option 8" },
|
||||
{ id: "9", label: "Option 9" },
|
||||
{ id: "10", label: "Option 10" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هي الميزات التي تستخدمها؟",
|
||||
dir: "rtl",
|
||||
description: "اختر كل ما ينطبق",
|
||||
options: [
|
||||
{ id: "opt-1", label: "الخيار الأول" },
|
||||
{ id: "opt-2", label: "الخيار الثاني" },
|
||||
{ id: "opt-3", label: "الخيار الثالث" },
|
||||
{ id: "opt-4", label: "الخيار الرابع" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelections: Story = {
|
||||
args: {
|
||||
headline: "ما هي اهتماماتك؟",
|
||||
dir: "rtl",
|
||||
description: "يرجى اختيار جميع الخيارات المناسبة",
|
||||
options: [
|
||||
{ id: "tech", label: "التكنولوجيا" },
|
||||
{ id: "design", label: "التصميم" },
|
||||
{ id: "marketing", label: "التسويق" },
|
||||
{ id: "sales", label: "المبيعات" },
|
||||
],
|
||||
value: ["tech", "design"],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<MultiSelect
|
||||
elementId="features"
|
||||
inputId="features-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<MultiSelect
|
||||
elementId="languages"
|
||||
inputId="languages-input"
|
||||
headline="What programming languages do you know?"
|
||||
options={[
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
]}
|
||||
value={["js", "ts"]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Dropdown: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
variant: "dropdown",
|
||||
placeholder: "Select options...",
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithSelections: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
value: ["option-1", "option-3"],
|
||||
variant: "dropdown",
|
||||
placeholder: "Select options...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>([]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-other"
|
||||
inputId="multi-select-other-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOptionSelected: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>(["option-1", "other"]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("Custom feature");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-other-selected"
|
||||
inputId="multi-select-other-selected-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>([]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-dropdown-other"
|
||||
inputId="multi-select-dropdown-other-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variant="dropdown"
|
||||
placeholder="Select options..."
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
549
packages/survey-ui/src/components/elements/multi-select.tsx
Normal file
549
packages/survey-ui/src/components/elements/multi-select.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/general/button";
|
||||
import { Checkbox } from "@/components/general/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/general/dropdown-menu";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Option for multi-select element
|
||||
*/
|
||||
export interface MultiSelectOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content)
|
||||
*/
|
||||
type TextDirection = "ltr" | "rtl" | "auto";
|
||||
|
||||
interface MultiSelectProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the multi-select group */
|
||||
inputId: string;
|
||||
/** Array of options to choose from */
|
||||
options: MultiSelectOption[];
|
||||
/** Currently selected option IDs */
|
||||
value?: string[];
|
||||
/** Callback function called when selection changes */
|
||||
onChange: (value: string[]) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display below the options */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: TextDirection;
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
/** Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu */
|
||||
variant?: "list" | "dropdown";
|
||||
/** Placeholder text for dropdown button when no options are selected */
|
||||
placeholder?: string;
|
||||
/** ID for the 'other' option that allows custom input */
|
||||
otherOptionId?: string;
|
||||
/** Label for the 'other' option */
|
||||
otherOptionLabel?: string;
|
||||
/** Placeholder text for the 'other' input field */
|
||||
otherOptionPlaceholder?: string;
|
||||
/** Custom value entered in the 'other' input field */
|
||||
otherValue?: string;
|
||||
/** Callback when the 'other' input value changes */
|
||||
onOtherValueChange?: (value: string) => void;
|
||||
/** IDs of options that should be exclusive (selecting them deselects all others) */
|
||||
exclusiveOptionIds?: string[];
|
||||
}
|
||||
|
||||
// Shared className for option labels
|
||||
const optionLabelClassName = "font-option text-option font-option-weight text-option-label";
|
||||
|
||||
// Shared className for option containers
|
||||
const getOptionContainerClassName = (isSelected: boolean, isDisabled: boolean): string =>
|
||||
cn(
|
||||
"relative flex flex-col border transition-colors outline-none",
|
||||
"rounded-option px-option-x py-option-y",
|
||||
isSelected ? "bg-option-selected-bg border-brand" : "bg-option-bg border-option-border",
|
||||
"focus-within:border-brand focus-within:bg-option-selected-bg",
|
||||
"hover:bg-option-hover-bg",
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||
);
|
||||
|
||||
interface DropdownVariantProps {
|
||||
inputId: string;
|
||||
options: MultiSelectOption[];
|
||||
selectedValues: string[];
|
||||
handleOptionAdd: (optionId: string) => void;
|
||||
handleOptionRemove: (optionId: string) => void;
|
||||
disabled: boolean;
|
||||
headline: string;
|
||||
errorMessage?: string;
|
||||
displayText: string;
|
||||
hasOtherOption: boolean;
|
||||
otherOptionId?: string;
|
||||
isOtherSelected: boolean;
|
||||
otherOptionLabel: string;
|
||||
otherValue: string;
|
||||
handleOtherInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
otherOptionPlaceholder: string;
|
||||
dir: TextDirection;
|
||||
otherInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
function DropdownVariant({
|
||||
inputId,
|
||||
options,
|
||||
selectedValues,
|
||||
handleOptionAdd,
|
||||
handleOptionRemove,
|
||||
disabled,
|
||||
headline,
|
||||
errorMessage,
|
||||
displayText,
|
||||
hasOtherOption,
|
||||
otherOptionId,
|
||||
isOtherSelected,
|
||||
otherOptionLabel,
|
||||
otherValue,
|
||||
handleOtherInputChange,
|
||||
otherOptionPlaceholder,
|
||||
dir,
|
||||
otherInputRef,
|
||||
required,
|
||||
}: Readonly<DropdownVariantProps>): React.JSX.Element {
|
||||
const getIsRequired = (): boolean => {
|
||||
const responseValues = [...selectedValues];
|
||||
if (isOtherSelected && otherValue) {
|
||||
responseValues.push(otherValue);
|
||||
}
|
||||
const hasResponse = Array.isArray(responseValues) && responseValues.length > 0;
|
||||
return required && hasResponse ? false : required;
|
||||
};
|
||||
|
||||
const isRequired = getIsRequired();
|
||||
|
||||
const handleOptionToggle = (optionId: string) => {
|
||||
if (selectedValues.includes(optionId)) {
|
||||
handleOptionRemove(optionId);
|
||||
} else {
|
||||
handleOptionAdd(optionId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="rounded-input w-full justify-between"
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
aria-label={headline}>
|
||||
<span className="truncate">{displayText}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]" align="start">
|
||||
{options
|
||||
.filter((option) => option.id !== "none")
|
||||
.map((option) => {
|
||||
const isChecked = selectedValues.includes(option.id);
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={option.id}
|
||||
id={optionId}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => {
|
||||
handleOptionToggle(option.id);
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{option.label}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<DropdownMenuCheckboxItem
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
checked={isOtherSelected}
|
||||
onCheckedChange={() => {
|
||||
if (isOtherSelected) {
|
||||
handleOptionRemove(otherOptionId);
|
||||
} else {
|
||||
handleOptionAdd(otherOptionId);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{otherOptionLabel}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : null}
|
||||
{options
|
||||
.filter((option) => option.id === "none")
|
||||
.map((option) => {
|
||||
const isChecked = selectedValues.includes(option.id);
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={option.id}
|
||||
id={optionId}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => {
|
||||
handleOptionToggle(option.id);
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{option.label}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isOtherSelected ? (
|
||||
<Input
|
||||
ref={otherInputRef}
|
||||
type="text"
|
||||
value={otherValue}
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
required={isRequired}
|
||||
aria-required={required}
|
||||
dir={dir}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListVariantProps {
|
||||
inputId: string;
|
||||
options: MultiSelectOption[];
|
||||
selectedValues: string[];
|
||||
value: string[];
|
||||
handleOptionAdd: (optionId: string) => void;
|
||||
handleOptionRemove: (optionId: string) => void;
|
||||
disabled: boolean;
|
||||
headline: string;
|
||||
errorMessage?: string;
|
||||
hasOtherOption: boolean;
|
||||
otherOptionId?: string;
|
||||
isOtherSelected: boolean;
|
||||
otherOptionLabel: string;
|
||||
otherValue: string;
|
||||
handleOtherInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
otherOptionPlaceholder: string;
|
||||
dir: TextDirection;
|
||||
otherInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
function ListVariant({
|
||||
inputId,
|
||||
options,
|
||||
selectedValues,
|
||||
value,
|
||||
handleOptionAdd,
|
||||
handleOptionRemove,
|
||||
disabled,
|
||||
headline,
|
||||
errorMessage,
|
||||
hasOtherOption,
|
||||
otherOptionId,
|
||||
isOtherSelected,
|
||||
otherOptionLabel,
|
||||
otherValue,
|
||||
handleOtherInputChange,
|
||||
otherOptionPlaceholder,
|
||||
dir,
|
||||
otherInputRef,
|
||||
required,
|
||||
}: Readonly<ListVariantProps>): React.JSX.Element {
|
||||
const isNoneSelected = value.includes("none");
|
||||
|
||||
const getIsRequired = (): boolean => {
|
||||
const responseValues = [...value];
|
||||
if (isOtherSelected && otherValue) {
|
||||
responseValues.push(otherValue);
|
||||
}
|
||||
const hasResponse = Array.isArray(responseValues) && responseValues.length > 0;
|
||||
return required && hasResponse ? false : required;
|
||||
};
|
||||
|
||||
const isRequired = getIsRequired();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="space-y-2" aria-label={headline}>
|
||||
{options
|
||||
.filter((option) => option.id !== "none")
|
||||
.map((option, index) => {
|
||||
const isChecked = selectedValues.includes(option.id);
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
const isDisabled = disabled || (isNoneSelected && option.id !== "none");
|
||||
// Only mark the first checkbox as required for HTML5 validation
|
||||
// This ensures at least one selection is required, not all
|
||||
const isFirstOption = index === 0;
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
className={cn(getOptionContainerClassName(isChecked, isDisabled), isChecked && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<Checkbox
|
||||
id={optionId}
|
||||
name={inputId}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked === true) {
|
||||
handleOptionAdd(option.id);
|
||||
} else {
|
||||
handleOptionRemove(option.id);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
required={isRequired ? isFirstOption : false}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={`${inputId}-${otherOptionId}`}
|
||||
className={cn(
|
||||
getOptionContainerClassName(isOtherSelected, disabled || isNoneSelected),
|
||||
isOtherSelected && "z-10"
|
||||
)}>
|
||||
<span className="flex items-center">
|
||||
<Checkbox
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
name={inputId}
|
||||
checked={isOtherSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked === true) {
|
||||
handleOptionAdd(otherOptionId);
|
||||
} else {
|
||||
handleOptionRemove(otherOptionId);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || isNoneSelected}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{otherOptionLabel}
|
||||
</span>
|
||||
</span>
|
||||
{isOtherSelected ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={otherValue}
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
required
|
||||
aria-required={required}
|
||||
dir={dir}
|
||||
className="mt-2 w-full"
|
||||
ref={otherInputRef}
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
{options
|
||||
.filter((option) => option.id === "none")
|
||||
.map((option) => {
|
||||
const isChecked = selectedValues.includes(option.id);
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
const isDisabled = disabled || (isNoneSelected && option.id !== "none");
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
className={cn(getOptionContainerClassName(isChecked, isDisabled), isChecked && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<Checkbox
|
||||
id={optionId}
|
||||
name={inputId}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked === true) {
|
||||
handleOptionAdd(option.id);
|
||||
} else {
|
||||
handleOptionRemove(option.id);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
required={false}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelect({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
variant = "list",
|
||||
placeholder = "Select options...",
|
||||
otherOptionId,
|
||||
otherOptionLabel = "Other",
|
||||
otherOptionPlaceholder = "Please specify",
|
||||
otherValue = "",
|
||||
onOtherValueChange,
|
||||
exclusiveOptionIds = [],
|
||||
}: Readonly<MultiSelectProps>): React.JSX.Element {
|
||||
// Ensure value is always an array
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
const hasOtherOption = Boolean(otherOptionId);
|
||||
const isOtherSelected = Boolean(hasOtherOption && otherOptionId && selectedValues.includes(otherOptionId));
|
||||
const otherInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOtherSelected || disabled) return;
|
||||
|
||||
// Delay focus to win against Radix focus restoration when dropdown closes / checkbox receives focus.
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
globalThis.requestAnimationFrame(() => {
|
||||
otherInputRef.current?.focus();
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isOtherSelected, disabled, variant]);
|
||||
|
||||
const handleOptionAdd = (optionId: string): void => {
|
||||
if (exclusiveOptionIds.includes(optionId)) {
|
||||
onChange([optionId]);
|
||||
} else {
|
||||
const newValues = selectedValues.filter((id) => !exclusiveOptionIds.includes(id));
|
||||
onChange([...newValues, optionId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionRemove = (optionId: string): void => {
|
||||
onChange(selectedValues.filter((id) => id !== optionId));
|
||||
};
|
||||
|
||||
const handleOtherInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
onOtherValueChange?.(e.target.value);
|
||||
};
|
||||
|
||||
// Get selected option labels for dropdown display
|
||||
const selectedLabels = options.filter((opt) => selectedValues.includes(opt.id)).map((opt) => opt.label);
|
||||
|
||||
let displayText = placeholder;
|
||||
if (selectedLabels.length > 0) {
|
||||
displayText =
|
||||
selectedLabels.length === 1 ? selectedLabels[0] : `${String(selectedLabels.length)} selected`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative">
|
||||
{variant === "dropdown" ? (
|
||||
<DropdownVariant
|
||||
inputId={inputId}
|
||||
options={options}
|
||||
selectedValues={selectedValues}
|
||||
handleOptionAdd={handleOptionAdd}
|
||||
handleOptionRemove={handleOptionRemove}
|
||||
disabled={disabled}
|
||||
headline={headline}
|
||||
errorMessage={errorMessage}
|
||||
displayText={displayText}
|
||||
hasOtherOption={hasOtherOption}
|
||||
otherOptionId={otherOptionId}
|
||||
isOtherSelected={isOtherSelected}
|
||||
otherOptionLabel={otherOptionLabel}
|
||||
otherValue={otherValue}
|
||||
handleOtherInputChange={handleOtherInputChange}
|
||||
otherOptionPlaceholder={otherOptionPlaceholder}
|
||||
dir={dir}
|
||||
otherInputRef={otherInputRef}
|
||||
required={required}
|
||||
/>
|
||||
) : (
|
||||
<ListVariant
|
||||
inputId={inputId}
|
||||
options={options}
|
||||
selectedValues={selectedValues}
|
||||
value={value}
|
||||
handleOptionAdd={handleOptionAdd}
|
||||
handleOptionRemove={handleOptionRemove}
|
||||
disabled={disabled}
|
||||
headline={headline}
|
||||
errorMessage={errorMessage}
|
||||
hasOtherOption={hasOtherOption}
|
||||
otherOptionId={otherOptionId}
|
||||
isOtherSelected={isOtherSelected}
|
||||
otherOptionLabel={otherOptionLabel}
|
||||
otherValue={otherValue}
|
||||
handleOtherInputChange={handleOtherInputChange}
|
||||
otherOptionPlaceholder={otherOptionPlaceholder}
|
||||
dir={dir}
|
||||
otherInputRef={otherInputRef}
|
||||
required={required}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MultiSelect };
|
||||
export type { MultiSelectProps };
|
||||
244
packages/survey-ui/src/components/elements/nps.stories.tsx
Normal file
244
packages/survey-ui/src/components/elements/nps.stories.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { NPS, type NPSProps } from "./nps";
|
||||
|
||||
type StoryProps = NPSProps & Partial<BaseStylingOptions & LabelStylingOptions> & Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/NPS",
|
||||
component: NPS,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A Net Promoter Score (NPS) element. Users can select a rating from 0 to 10 to indicate how likely they are to recommend something.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
value: {
|
||||
control: { type: "number", min: 0, max: 10 },
|
||||
description: "Currently selected NPS value (0-10)",
|
||||
table: { category: "State" },
|
||||
},
|
||||
lowerLabel: {
|
||||
control: "text",
|
||||
description: "Label for the lower end of the scale",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
upperLabel: {
|
||||
control: "text",
|
||||
description: "Label for the upper end of the scale",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
colorCoding: {
|
||||
control: "boolean",
|
||||
description: "Whether color coding is enabled",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(NPS),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "nps-1",
|
||||
inputId: "nps-input-1",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
description: "Please rate from 0 to 10",
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontWeight",
|
||||
"inputBorderRadius",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "nps-1",
|
||||
inputId: "nps-input-1",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "nps-2",
|
||||
inputId: "nps-input-2",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
description: "Please rate from 0 to 10, where 0 is not at all likely and 10 is extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabels: Story = {
|
||||
args: {
|
||||
elementId: "nps-labels",
|
||||
inputId: "nps-input-labels",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
elementId: "nps-selection",
|
||||
inputId: "nps-input-selection",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 9,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "nps-required",
|
||||
inputId: "nps-input-required",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "nps-error",
|
||||
inputId: "nps-input-error",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
required: true,
|
||||
errorMessage: "Please select a rating",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "nps-disabled",
|
||||
inputId: "nps-input-disabled",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 8,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorCoding: Story = {
|
||||
args: {
|
||||
elementId: "nps-color",
|
||||
inputId: "nps-input-color",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
colorCoding: true,
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const Promoter: Story = {
|
||||
args: {
|
||||
elementId: "nps-promoter",
|
||||
inputId: "nps-input-promoter",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 9,
|
||||
colorCoding: true,
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const Passive: Story = {
|
||||
args: {
|
||||
elementId: "nps-passive",
|
||||
inputId: "nps-input-passive",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 7,
|
||||
colorCoding: true,
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const Detractor: Story = {
|
||||
args: {
|
||||
elementId: "nps-detractor",
|
||||
inputId: "nps-input-detractor",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 5,
|
||||
colorCoding: true,
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "nps-rtl",
|
||||
dir: "rtl",
|
||||
inputId: "nps-input-rtl",
|
||||
headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟",
|
||||
description: "يرجى التقييم من 0 إلى 10",
|
||||
lowerLabel: "غير محتمل على الإطلاق",
|
||||
upperLabel: "محتمل للغاية",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelection: Story = {
|
||||
args: {
|
||||
elementId: "nps-rtl-selection",
|
||||
dir: "rtl",
|
||||
inputId: "nps-input-rtl-selection",
|
||||
headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟",
|
||||
value: 8,
|
||||
lowerLabel: "غير محتمل على الإطلاق",
|
||||
upperLabel: "محتمل للغاية",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<NPS
|
||||
elementId="nps-1"
|
||||
inputId="nps-input-1"
|
||||
headline="How likely are you to recommend our product?"
|
||||
lowerLabel="Not at all likely"
|
||||
upperLabel="Extremely likely"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<NPS
|
||||
elementId="nps-2"
|
||||
inputId="nps-input-2"
|
||||
headline="How likely are you to recommend our service?"
|
||||
description="Please rate from 0 to 10"
|
||||
value={9}
|
||||
colorCoding
|
||||
lowerLabel="Not at all likely"
|
||||
upperLabel="Extremely likely"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
196
packages/survey-ui/src/components/elements/nps.tsx
Normal file
196
packages/survey-ui/src/components/elements/nps.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NPSProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the NPS group */
|
||||
inputId: string;
|
||||
/** Currently selected NPS value (0 to 10) */
|
||||
value?: number;
|
||||
/** Callback function called when NPS value changes */
|
||||
onChange: (value: number) => void;
|
||||
/** Optional label for the lower end of the scale */
|
||||
lowerLabel?: string;
|
||||
/** Optional label for the upper end of the scale */
|
||||
upperLabel?: string;
|
||||
/** Whether color coding is enabled */
|
||||
colorCoding?: boolean;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function NPS({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
value,
|
||||
onChange,
|
||||
lowerLabel,
|
||||
upperLabel,
|
||||
colorCoding = false,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: Readonly<NPSProps>): React.JSX.Element {
|
||||
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
||||
|
||||
// Ensure value is within valid range (0-10)
|
||||
const currentValue = value !== undefined && value >= 0 && value <= 10 ? value : undefined;
|
||||
|
||||
// Handle NPS selection
|
||||
const handleSelect = (npsValue: number): void => {
|
||||
if (!disabled) {
|
||||
onChange(npsValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (npsValue: number) => (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect(npsValue);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
const direction = e.key === "ArrowLeft" ? -1 : 1;
|
||||
const newValue = Math.max(0, Math.min(10, (currentValue ?? 0) + direction));
|
||||
handleSelect(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Get NPS option color for color coding
|
||||
const getNPSOptionColor = (idx: number): string => {
|
||||
if (idx > 8) return "bg-emerald-100"; // 9-10: Promoters (green)
|
||||
if (idx > 6) return "bg-orange-100"; // 7-8: Passives (orange)
|
||||
return "bg-rose-100"; // 0-6: Detractors (red)
|
||||
};
|
||||
|
||||
// Render NPS option (0-10)
|
||||
const renderNPSOption = (number: number): React.JSX.Element => {
|
||||
const isSelected = currentValue === number;
|
||||
const isHovered = hoveredValue === number;
|
||||
const isLast = number === 10; // Last option is 10
|
||||
const isFirst = number === 0; // First option is 0
|
||||
|
||||
// Determine border radius and border classes
|
||||
// Use right border for all items to create separators, left border only on first item
|
||||
let borderRadiusClasses = "";
|
||||
let borderClasses = "border-t border-b border-r";
|
||||
|
||||
if (isFirst) {
|
||||
borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input";
|
||||
borderClasses = "border-t border-b border-l border-r";
|
||||
} else if (isLast) {
|
||||
borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input";
|
||||
// Last item keeps right border for rounded corner
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
<label
|
||||
key={number}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown(number)}
|
||||
className={cn(
|
||||
"text-input-text font-input font-input-weight relative flex w-full cursor-pointer items-center justify-center overflow-hidden transition-colors focus:outline-none",
|
||||
borderClasses,
|
||||
isSelected
|
||||
? "bg-brand-20 border-brand z-10 -ml-[1px] border-2 first:ml-0"
|
||||
: "border-input-border bg-input-bg",
|
||||
borderRadiusClasses,
|
||||
isHovered && !isSelected && "bg-input-selected-bg",
|
||||
colorCoding ? "min-h-[47px]" : "min-h-[41px]",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
"focus:border-brand focus:border-2"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHoveredValue(null);
|
||||
}}>
|
||||
{colorCoding ? (
|
||||
<div className={cn("absolute top-0 left-0 h-[6px] w-full", getNPSOptionColor(number))} />
|
||||
) : null}
|
||||
<input
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={number}
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of 10`}
|
||||
/>
|
||||
<span className="text-sm">{number}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// Generate NPS options (0-10)
|
||||
const npsOptions = Array.from({ length: 11 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* NPS Options */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full px-[2px]">
|
||||
<legend className="sr-only">NPS rating options</legend>
|
||||
<div className="flex w-full">{npsOptions.map((number) => renderNPSOption(number))}</div>
|
||||
|
||||
{/* Labels */}
|
||||
{(lowerLabel ?? upperLabel) ? (
|
||||
<div className="mt-2 flex justify-between gap-8 px-1.5">
|
||||
{lowerLabel ? (
|
||||
<Label variant="default" className="max-w-[50%] text-xs leading-6" dir={dir}>
|
||||
{lowerLabel}
|
||||
</Label>
|
||||
) : null}
|
||||
{upperLabel ? (
|
||||
<Label variant="default" className="max-w-[50%] text-right text-xs leading-6" dir={dir}>
|
||||
{upperLabel}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NPS };
|
||||
export type { NPSProps };
|
||||
279
packages/survey-ui/src/components/elements/open-text.stories.tsx
Normal file
279
packages/survey-ui/src/components/elements/open-text.stories.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { OpenText, type OpenTextProps } from "./open-text";
|
||||
|
||||
type StoryProps = OpenTextProps &
|
||||
Partial<BaseStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/OpenText",
|
||||
component: OpenText,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete open text element that combines headline, description, and input/textarea components. Supports short and long answers, validation, character limits, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for the input field",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Current input value",
|
||||
table: { category: "State" },
|
||||
},
|
||||
longAnswer: {
|
||||
control: "boolean",
|
||||
description: "Use textarea for long-form answers instead of input",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
inputType: {
|
||||
control: { type: "select" },
|
||||
options: ["text", "email", "url", "phone", "number"],
|
||||
description: "Type of input field (only used when longAnswer is false)",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
charLimit: {
|
||||
control: "object",
|
||||
description: "Character limit configuration {min?, max?}",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
rows: {
|
||||
control: { type: "number", min: 1, max: 20 },
|
||||
description: "Number of rows for textarea (only when longAnswer is true)",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(OpenText),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "What's your feedback?",
|
||||
description: "Please share your thoughts with us",
|
||||
placeholder: "Type your answer here...",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontSize",
|
||||
"inputFontWeight",
|
||||
"inputWidth",
|
||||
"inputHeight",
|
||||
"inputBorderRadius",
|
||||
"inputPlaceholderColor",
|
||||
"inputPaddingX",
|
||||
"inputPaddingY",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "What's your feedback?",
|
||||
placeholder: "Type your answer here...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "What did you think of our service?",
|
||||
description: "We'd love to hear your honest feedback to help us improve",
|
||||
placeholder: "Share your thoughts...",
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "What's your email address?",
|
||||
description: "We'll use this to contact you",
|
||||
placeholder: "email@example.com",
|
||||
required: true,
|
||||
inputType: "email",
|
||||
},
|
||||
};
|
||||
|
||||
export const LongAnswer: Story = {
|
||||
args: {
|
||||
headline: "Tell us about your experience",
|
||||
description: "Please provide as much detail as possible",
|
||||
placeholder: "Write your detailed response here...",
|
||||
longAnswer: true,
|
||||
rows: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const LongAnswerWithCharLimit: Story = {
|
||||
args: {
|
||||
headline: "Share your story",
|
||||
description: "Maximum 500 characters",
|
||||
placeholder: "Tell us your story...",
|
||||
longAnswer: true,
|
||||
rows: 6,
|
||||
charLimit: {
|
||||
max: 500,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmailInput: Story = {
|
||||
args: {
|
||||
headline: "What's your email?",
|
||||
inputType: "email",
|
||||
placeholder: "email@example.com",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const PhoneInput: Story = {
|
||||
args: {
|
||||
headline: "What's your phone number?",
|
||||
description: "Include country code",
|
||||
inputType: "phone",
|
||||
placeholder: "+1 (555) 123-4567",
|
||||
},
|
||||
};
|
||||
|
||||
export const URLInput: Story = {
|
||||
args: {
|
||||
headline: "What's your website?",
|
||||
inputType: "url",
|
||||
placeholder: "https://example.com",
|
||||
},
|
||||
};
|
||||
|
||||
export const NumberInput: Story = {
|
||||
args: {
|
||||
headline: "How many employees does your company have?",
|
||||
inputType: "number",
|
||||
placeholder: "0",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "What's your email address?",
|
||||
inputType: "email",
|
||||
placeholder: "email@example.com",
|
||||
value: "invalid-email",
|
||||
errorMessage: "Please enter a valid email address",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
headline: "What's your name?",
|
||||
placeholder: "Enter your name",
|
||||
value: "John Doe",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This field is disabled",
|
||||
description: "You cannot edit this field",
|
||||
placeholder: "Disabled input",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledWithValue: Story = {
|
||||
args: {
|
||||
headline: "Submission ID",
|
||||
value: "SUB-2024-001",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو تقييمك؟",
|
||||
description: "يرجى مشاركة أفكارك معنا",
|
||||
placeholder: "اكتب إجابتك هنا...",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLLongAnswer: Story = {
|
||||
args: {
|
||||
headline: "أخبرنا عن تجربتك",
|
||||
description: "يرجى تقديم أكبر قدر ممكن من التفاصيل",
|
||||
placeholder: "اكتب ردك التفصيلي هنا...",
|
||||
longAnswer: true,
|
||||
rows: 5,
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithErrorAndRTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو بريدك الإلكتروني؟",
|
||||
inputType: "email",
|
||||
placeholder: "email@example.com",
|
||||
errorMessage: "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||
required: true,
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<OpenText
|
||||
elementId="name"
|
||||
inputId="name"
|
||||
headline="What's your name?"
|
||||
placeholder="Enter your name"
|
||||
required
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<OpenText
|
||||
elementId="email"
|
||||
inputId="email"
|
||||
headline="What's your email?"
|
||||
inputType="email"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<OpenText
|
||||
elementId="bio"
|
||||
inputId="bio"
|
||||
headline="Tell us about yourself"
|
||||
description="Optional: Share a bit about your background"
|
||||
placeholder="Your bio..."
|
||||
longAnswer
|
||||
rows={4}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
110
packages/survey-ui/src/components/elements/open-text.tsx
Normal file
110
packages/survey-ui/src/components/elements/open-text.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { Textarea } from "@/components/general/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OpenTextProps {
|
||||
elementId: string;
|
||||
headline: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
inputId: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
longAnswer?: boolean;
|
||||
inputType?: "text" | "email" | "url" | "phone" | "number";
|
||||
charLimit?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
errorMessage?: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function OpenText({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
placeholder,
|
||||
value = "",
|
||||
inputId,
|
||||
onChange,
|
||||
required = false,
|
||||
longAnswer = false,
|
||||
inputType = "text",
|
||||
charLimit,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
rows = 3,
|
||||
disabled = false,
|
||||
}: Readonly<OpenTextProps>): React.JSX.Element {
|
||||
const [currentLength, setCurrentLength] = useState(value.length);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
const newValue = e.target.value;
|
||||
setCurrentLength(newValue.length);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const renderCharLimit = (): React.JSX.Element | null => {
|
||||
if (charLimit?.max === undefined) return null;
|
||||
const isOverLimit = currentLength >= charLimit.max;
|
||||
return (
|
||||
<span className={cn("text-xs", isOverLimit ? "font-semibold text-red-500" : "text-brand")}>
|
||||
{currentLength}/{charLimit.max}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Input or Textarea */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<div className="space-y-1">
|
||||
{longAnswer ? (
|
||||
<Textarea
|
||||
id={inputId}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
required={required}
|
||||
dir={dir}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
minLength={charLimit?.min}
|
||||
maxLength={charLimit?.max}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
required={required}
|
||||
dir={dir}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
minLength={charLimit?.min}
|
||||
maxLength={charLimit?.max}
|
||||
/>
|
||||
)}
|
||||
{renderCharLimit()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { OpenText };
|
||||
export type { OpenTextProps };
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
} from "../../lib/story-helpers";
|
||||
import { PictureSelect, type PictureSelectOption, type PictureSelectProps } from "./picture-select";
|
||||
|
||||
type StoryProps = PictureSelectProps &
|
||||
Partial<BaseStylingOptions & { optionBorderRadius: string }> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/PictureSelect",
|
||||
component: PictureSelect,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete picture selection element that combines headline, description, and a grid of selectable images. Supports both single and multi-select modes, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of picture options to choose from",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Selected option ID(s) - string for single select, string[] for multi select",
|
||||
table: { category: "State" },
|
||||
},
|
||||
allowMulti: {
|
||||
control: "boolean",
|
||||
description: "Whether multiple selections are allowed",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(PictureSelect),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
// Sample image URLs - using placeholder images
|
||||
const defaultOptions: PictureSelectOption[] = [
|
||||
{
|
||||
id: "option-1",
|
||||
imageUrl: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=300&h=200&fit=crop",
|
||||
alt: "Mountain landscape",
|
||||
},
|
||||
{
|
||||
id: "option-2",
|
||||
imageUrl: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=300&h=200&fit=crop",
|
||||
alt: "Ocean view",
|
||||
},
|
||||
{
|
||||
id: "option-3",
|
||||
imageUrl: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=300&h=200&fit=crop",
|
||||
alt: "Forest path",
|
||||
},
|
||||
{
|
||||
id: "option-4",
|
||||
imageUrl: "https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=300&h=200&fit=crop",
|
||||
alt: "Desert scene",
|
||||
},
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
description: "Select one or more images",
|
||||
options: defaultOptions,
|
||||
allowMulti: false,
|
||||
},
|
||||
argTypes: {
|
||||
// Element styling
|
||||
elementHeadlineFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
brandColor: {
|
||||
control: "color",
|
||||
table: { category: "Survey Styling" },
|
||||
},
|
||||
optionBorderRadius: {
|
||||
control: "text",
|
||||
description: "Border radius for picture options",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "Select your favorite travel destination",
|
||||
description: "Choose the image that appeals to you most",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleSelect: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
description: "Select one image",
|
||||
options: defaultOptions,
|
||||
allowMulti: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: Story = {
|
||||
args: {
|
||||
headline: "Select all images you like",
|
||||
description: "You can select multiple images",
|
||||
options: defaultOptions,
|
||||
allowMulti: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
description: "Please select an image",
|
||||
options: defaultOptions,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithMultipleSelections: Story = {
|
||||
args: {
|
||||
headline: "Select all images you like",
|
||||
description: "You can select multiple images",
|
||||
options: defaultOptions,
|
||||
allowMulti: true,
|
||||
value: ["option-1", "option-3"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
description: "Please select an image",
|
||||
options: defaultOptions,
|
||||
errorMessage: "Please select at least one image",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
headline: "Select your favorite images",
|
||||
description: "Choose from the images below",
|
||||
options: [
|
||||
...defaultOptions,
|
||||
{
|
||||
id: "option-5",
|
||||
imageUrl: "https://images.unsplash.com/photo-1519681393784-d120267933ba?w=300&h=200&fit=crop",
|
||||
alt: "City skyline",
|
||||
},
|
||||
{
|
||||
id: "option-6",
|
||||
imageUrl: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=300&h=200&fit=crop",
|
||||
alt: "Sunset",
|
||||
},
|
||||
],
|
||||
allowMulti: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هي الصورة التي تفضلها؟",
|
||||
dir: "rtl",
|
||||
description: "اختر صورة واحدة",
|
||||
options: defaultOptions.map((opt) => ({ ...opt, alt: "نص بديل" })),
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelection: Story = {
|
||||
args: {
|
||||
headline: "اختر الصور التي تعجبك",
|
||||
dir: "rtl",
|
||||
description: "يمكنك اختيار عدة صور",
|
||||
options: defaultOptions.map((opt) => ({ ...opt, alt: "نص بديل" })),
|
||||
allowMulti: true,
|
||||
value: ["option-1", "option-2"],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => {
|
||||
const [value1, setValue1] = React.useState<string | string[] | undefined>(undefined);
|
||||
const [value2, setValue2] = React.useState<string | string[]>(["option-1", "option-3"]);
|
||||
|
||||
return (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<PictureSelect
|
||||
elementId="picture-1"
|
||||
inputId="picture-1-input"
|
||||
headline="Which image do you prefer?"
|
||||
description="Select one image"
|
||||
options={defaultOptions}
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
/>
|
||||
<PictureSelect
|
||||
elementId="picture-2"
|
||||
inputId="picture-2-input"
|
||||
headline="Select all images you like"
|
||||
description="You can select multiple images"
|
||||
options={defaultOptions}
|
||||
allowMulti
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
198
packages/survey-ui/src/components/elements/picture-select.tsx
Normal file
198
packages/survey-ui/src/components/elements/picture-select.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import * as React from "react";
|
||||
import { Checkbox } from "@/components/general/checkbox";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { RadioGroupItem } from "@/components/general/radio-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Picture option for picture select element
|
||||
*/
|
||||
export interface PictureSelectOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** URL of the image */
|
||||
imageUrl: string;
|
||||
/** Alt text for the image */
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface PictureSelectProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the picture select group */
|
||||
inputId: string;
|
||||
/** Array of picture options to choose from */
|
||||
options: PictureSelectOption[];
|
||||
/** Currently selected option ID(s) - string for single select, string[] for multi select */
|
||||
value?: string | string[];
|
||||
/** Callback function called when selection changes */
|
||||
onChange: (value: string | string[]) => void;
|
||||
/** Whether multiple selections are allowed */
|
||||
allowMulti?: boolean;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function PictureSelect({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
allowMulti = false,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: Readonly<PictureSelectProps>): React.JSX.Element {
|
||||
// Ensure value is always the correct type
|
||||
let selectedValues: string[] | string | undefined;
|
||||
if (allowMulti) {
|
||||
selectedValues = Array.isArray(value) ? value : [];
|
||||
} else {
|
||||
selectedValues = typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
const handleMultiSelectChange = (optionId: string, checked: boolean): void => {
|
||||
if (disabled) return;
|
||||
|
||||
const currentArray = Array.isArray(value) ? value : [];
|
||||
if (checked) {
|
||||
onChange([...currentArray, optionId]);
|
||||
} else {
|
||||
onChange(currentArray.filter((id) => id !== optionId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleSelectChange = (newValue: string): void => {
|
||||
if (disabled) return;
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Picture Grid - 2 columns */}
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{allowMulti ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = (selectedValues as string[]).includes(option.id);
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
className={cn(
|
||||
"rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}>
|
||||
{/* Image container with border when selected */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-option absolute inset-[2px] overflow-hidden",
|
||||
isSelected && "border-brand border-4 border-solid"
|
||||
)}>
|
||||
<img
|
||||
src={option.imageUrl}
|
||||
alt={option.alt ?? `Option ${option.id}`}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{/* Selection indicator - Checkbox for multi select */}
|
||||
<div
|
||||
className="absolute top-[5%] right-[5%]"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<Checkbox
|
||||
id={optionId}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
handleMultiSelectChange(option.id, checked === true);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4"
|
||||
aria-label={option.alt ?? `Select ${option.id}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<RadioGroupPrimitive.Root
|
||||
value={selectedValues as string}
|
||||
onValueChange={handleSingleSelectChange}
|
||||
disabled={disabled}
|
||||
className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => {
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
const isSelected = selectedValues === option.id;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
className={cn(
|
||||
"rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}>
|
||||
{/* Image container with border when selected */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-option absolute inset-[2px] overflow-hidden",
|
||||
isSelected && "border-brand border-4 border-solid"
|
||||
)}>
|
||||
<img
|
||||
src={option.imageUrl}
|
||||
alt={option.alt ?? `Option ${option.id}`}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{/* Selection indicator - Radio button for single select */}
|
||||
<div
|
||||
className="absolute top-[5%] right-[5%]"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 bg-white"
|
||||
aria-label={option.alt ?? `Select ${option.id}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroupPrimitive.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PictureSelect };
|
||||
export type { PictureSelectProps };
|
||||
222
packages/survey-ui/src/components/elements/ranking.stories.tsx
Normal file
222
packages/survey-ui/src/components/elements/ranking.stories.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type OptionStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
optionStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Ranking, type RankingOption, type RankingProps } from "./ranking";
|
||||
|
||||
type StoryProps = RankingProps & Partial<BaseStylingOptions & OptionStylingOptions> & Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Ranking",
|
||||
component: Ranking,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A ranking element that allows users to order items by clicking them. Users can reorder ranked items using up/down buttons.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of options to rank",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Currently ranked option IDs in order",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Ranking),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
// Default options for stories
|
||||
const defaultOptions: RankingOption[] = [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
{ id: "3", label: "Option 3" },
|
||||
{ id: "4", label: "Option 4" },
|
||||
{ id: "5", label: "Option 5" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "ranking-1",
|
||||
inputId: "ranking-input-1",
|
||||
headline: "Rank these items in order of importance",
|
||||
description: "Click items to add them to your ranking, then use arrows to reorder",
|
||||
options: defaultOptions,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...optionStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "ranking-1",
|
||||
inputId: "ranking-input-1",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "ranking-2",
|
||||
inputId: "ranking-input-2",
|
||||
headline: "Rank these items in order of importance",
|
||||
description: "Click items to add them to your ranking, then use the arrows to reorder them",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRanking: Story = {
|
||||
args: {
|
||||
elementId: "ranking-3",
|
||||
inputId: "ranking-input-3",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
value: ["3", "1", "5"],
|
||||
},
|
||||
};
|
||||
|
||||
export const FullyRanked: Story = {
|
||||
args: {
|
||||
elementId: "ranking-4",
|
||||
inputId: "ranking-input-4",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
value: ["5", "2", "1", "4", "3"],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "ranking-5",
|
||||
inputId: "ranking-input-5",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "ranking-6",
|
||||
inputId: "ranking-input-6",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
required: true,
|
||||
errorMessage: "Please rank all items",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "ranking-7",
|
||||
inputId: "ranking-input-7",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
value: ["2", "4"],
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
elementId: "ranking-8",
|
||||
inputId: "ranking-input-8",
|
||||
headline: "Rank these features by priority",
|
||||
description: "Click to add to ranking, use arrows to reorder",
|
||||
options: [
|
||||
{ id: "1", label: "Feature A" },
|
||||
{ id: "2", label: "Feature B" },
|
||||
{ id: "3", label: "Feature C" },
|
||||
{ id: "4", label: "Feature D" },
|
||||
{ id: "5", label: "Feature E" },
|
||||
{ id: "6", label: "Feature F" },
|
||||
{ id: "7", label: "Feature G" },
|
||||
{ id: "8", label: "Feature H" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "ranking-rtl",
|
||||
inputId: "ranking-input-rtl",
|
||||
dir: "rtl",
|
||||
headline: "رتب هذه العناصر حسب الأهمية",
|
||||
description: "انقر على العناصر لإضافتها إلى الترتيب، ثم استخدم الأسهم لإعادة الترتيب",
|
||||
options: [
|
||||
{ id: "1", label: "الخيار الأول" },
|
||||
{ id: "2", label: "الخيار الثاني" },
|
||||
{ id: "3", label: "الخيار الثالث" },
|
||||
{ id: "4", label: "الخيار الرابع" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithRanking: Story = {
|
||||
args: {
|
||||
elementId: "ranking-rtl-ranked",
|
||||
dir: "rtl",
|
||||
inputId: "ranking-input-rtl-ranked",
|
||||
headline: "رتب هذه العناصر حسب الأهمية",
|
||||
options: [
|
||||
{ id: "1", label: "الخيار الأول" },
|
||||
{ id: "2", label: "الخيار الثاني" },
|
||||
{ id: "3", label: "الخيار الثالث" },
|
||||
{ id: "4", label: "الخيار الرابع" },
|
||||
],
|
||||
value: ["3", "1", "4"],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Ranking
|
||||
elementId="ranking-1"
|
||||
inputId="ranking-input-1"
|
||||
headline="Rank these features by priority"
|
||||
options={defaultOptions}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Ranking
|
||||
elementId="ranking-2"
|
||||
inputId="ranking-input-2"
|
||||
headline="Rank these products by preference"
|
||||
description="Click items to rank them"
|
||||
options={[
|
||||
{ id: "1", label: "Product A" },
|
||||
{ id: "2", label: "Product B" },
|
||||
{ id: "3", label: "Product C" },
|
||||
]}
|
||||
value={["2", "1"]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
263
packages/survey-ui/src/components/elements/ranking.tsx
Normal file
263
packages/survey-ui/src/components/elements/ranking.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Text direction type for ranking element
|
||||
*/
|
||||
type TextDirection = "ltr" | "rtl" | "auto";
|
||||
|
||||
/**
|
||||
* Option for ranking element
|
||||
*/
|
||||
export interface RankingOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface RankingProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the ranking group */
|
||||
inputId: string;
|
||||
/** Array of options to rank */
|
||||
options: RankingOption[];
|
||||
/** Currently ranked option IDs in order (array of option IDs) */
|
||||
value?: string[];
|
||||
/** Callback function called when ranking changes */
|
||||
onChange: (value: string[]) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: TextDirection;
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface RankingItemProps {
|
||||
item: RankingOption;
|
||||
rankedIds: string[];
|
||||
onItemClick: (item: RankingOption) => void;
|
||||
onMove: (itemId: string, direction: "up" | "down") => void;
|
||||
disabled: boolean;
|
||||
dir?: TextDirection;
|
||||
}
|
||||
|
||||
function getTopButtonRadiusClass(isFirst: boolean, dir?: TextDirection): string {
|
||||
if (isFirst) {
|
||||
return "cursor-not-allowed opacity-30";
|
||||
}
|
||||
if (dir === "rtl") {
|
||||
return "rounded-tl-md";
|
||||
}
|
||||
return "rounded-tr-md";
|
||||
}
|
||||
|
||||
function getBottomButtonRadiusClass(isLast: boolean, dir?: TextDirection): string {
|
||||
if (isLast) {
|
||||
return "cursor-not-allowed opacity-30";
|
||||
}
|
||||
if (dir === "rtl") {
|
||||
return "rounded-bl-md";
|
||||
}
|
||||
return "rounded-br-md";
|
||||
}
|
||||
|
||||
function RankingItem({ item, rankedIds, onItemClick, onMove, disabled, dir }: Readonly<RankingItemProps>) {
|
||||
const isRanked = rankedIds.includes(item.id);
|
||||
const rankIndex = rankedIds.indexOf(item.id);
|
||||
const isFirst = isRanked && rankIndex === 0;
|
||||
const isLast = isRanked && rankIndex === rankedIds.length - 1;
|
||||
const displayNumber = isRanked ? rankIndex + 1 : undefined;
|
||||
|
||||
// RTL-aware padding class
|
||||
const paddingClass = dir === "rtl" ? "pr-3" : "pl-3";
|
||||
|
||||
// RTL-aware border class for control buttons
|
||||
const borderClass = dir === "rtl" ? "border-r" : "border-l";
|
||||
|
||||
// RTL-aware border radius classes for control buttons
|
||||
const topButtonRadiusClass = getTopButtonRadiusClass(isFirst, dir);
|
||||
const bottomButtonRadiusClass = getBottomButtonRadiusClass(isLast, dir);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-option flex h-12 cursor-pointer items-center border transition-all",
|
||||
paddingClass,
|
||||
"bg-option-bg border-option-border",
|
||||
"hover:bg-option-hover-bg focus-within:border-brand focus-within:bg-option-selected-bg focus-within:shadow-sm",
|
||||
isRanked && "bg-option-selected-bg border-brand",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onItemClick(item);
|
||||
}}
|
||||
disabled={disabled}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onItemClick(item);
|
||||
}
|
||||
}}
|
||||
className="group flex h-full grow items-center gap-4 text-start focus:outline-none"
|
||||
aria-label={isRanked ? `Remove ${item.label} from ranking` : `Add ${item.label} to ranking`}>
|
||||
<span
|
||||
className={cn(
|
||||
"border-brand flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold",
|
||||
isRanked
|
||||
? "bg-brand text-white"
|
||||
: "group-hover:bg-background group-hover:text-foreground border-dashed text-transparent"
|
||||
)}>
|
||||
{displayNumber}
|
||||
</span>
|
||||
<span
|
||||
className="font-option text-option font-option-weight text-option-label shrink grow text-start"
|
||||
dir={dir}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Up/Down buttons for ranked items */}
|
||||
{isRanked ? (
|
||||
<div className={cn("border-option-border flex h-full grow-0 flex-col", borderClass)}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isFirst ? -1 : 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onMove(item.id, "up");
|
||||
}}
|
||||
disabled={isFirst || disabled}
|
||||
aria-label={`Move ${item.label} up`}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center px-2 transition-colors",
|
||||
topButtonRadiusClass
|
||||
)}>
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isLast ? -1 : 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onMove(item.id, "down");
|
||||
}}
|
||||
disabled={isLast || disabled}
|
||||
aria-label={`Move ${item.label} down`}
|
||||
className={cn(
|
||||
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors",
|
||||
bottomButtonRadiusClass
|
||||
)}>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ranking({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: Readonly<RankingProps>): React.JSX.Element {
|
||||
// Ensure value is always an array
|
||||
const rankedIds = React.useMemo(() => (Array.isArray(value) ? value : []), [value]);
|
||||
|
||||
// Get sorted (ranked) items and unsorted items
|
||||
const sortedItems = React.useMemo(() => {
|
||||
return rankedIds
|
||||
.map((id) => options.find((opt) => opt.id === id))
|
||||
.filter((item): item is RankingOption => item !== undefined);
|
||||
}, [rankedIds, options]);
|
||||
|
||||
const unsortedItems = React.useMemo(() => {
|
||||
return options.filter((opt) => !rankedIds.includes(opt.id));
|
||||
}, [options, rankedIds]);
|
||||
|
||||
// Handle item click (add to ranking or remove from ranking)
|
||||
const handleItemClick = (item: RankingOption): void => {
|
||||
if (disabled) return;
|
||||
|
||||
const isAlreadyRanked = rankedIds.includes(item.id);
|
||||
const newRankedIds = isAlreadyRanked ? rankedIds.filter((id) => id !== item.id) : [...rankedIds, item.id];
|
||||
|
||||
onChange(newRankedIds);
|
||||
};
|
||||
|
||||
// Handle move up/down
|
||||
const handleMove = (itemId: string, direction: "up" | "down"): void => {
|
||||
if (disabled) return;
|
||||
|
||||
const index = rankedIds.indexOf(itemId);
|
||||
if (index === -1) return;
|
||||
|
||||
const newRankedIds = [...rankedIds];
|
||||
const [movedItem] = newRankedIds.splice(index, 1);
|
||||
const newIndex = direction === "up" ? Math.max(0, index - 1) : Math.min(newRankedIds.length, index + 1);
|
||||
newRankedIds.splice(newIndex, 0, movedItem);
|
||||
|
||||
onChange(newRankedIds);
|
||||
};
|
||||
|
||||
// Combine sorted and unsorted items for display
|
||||
const allItems = [...sortedItems, ...unsortedItems];
|
||||
|
||||
// Animation ref for smooth transitions
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Ranking Options */}
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<fieldset className="w-full" dir={dir}>
|
||||
<legend className="sr-only">Ranking options</legend>
|
||||
<div className="space-y-2" ref={parent as React.Ref<HTMLDivElement>}>
|
||||
{allItems.map((item) => (
|
||||
<RankingItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
rankedIds={rankedIds}
|
||||
onItemClick={handleItemClick}
|
||||
onMove={handleMove}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Ranking };
|
||||
export type { RankingProps };
|
||||
320
packages/survey-ui/src/components/elements/rating.stories.tsx
Normal file
320
packages/survey-ui/src/components/elements/rating.stories.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Rating, type RatingProps } from "./rating";
|
||||
|
||||
type StoryProps = RatingProps & Partial<BaseStylingOptions & LabelStylingOptions> & Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Rating",
|
||||
component: Rating,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A rating element that supports number, star, and smiley scales. Users can select a rating from 1 to the specified range.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
scale: {
|
||||
control: { type: "select" },
|
||||
options: ["number", "star", "smiley"],
|
||||
description: "Rating scale type",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
range: {
|
||||
control: { type: "select" },
|
||||
options: [3, 4, 5, 6, 7, 10],
|
||||
description: "Number of rating options",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: { type: "number", min: 1 },
|
||||
description: "Currently selected rating value",
|
||||
table: { category: "State" },
|
||||
},
|
||||
lowerLabel: {
|
||||
control: "text",
|
||||
description: "Label for the lower end of the scale",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
upperLabel: {
|
||||
control: "text",
|
||||
description: "Label for the upper end of the scale",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
colorCoding: {
|
||||
control: "boolean",
|
||||
description: "Whether color coding is enabled (for smiley scale)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Rating),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "rating-1",
|
||||
inputId: "rating-input-1",
|
||||
headline: "How satisfied are you?",
|
||||
description: "Please rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: "Not satisfied",
|
||||
upperLabel: "Very satisfied",
|
||||
elementHeadlineFontFamily: "system-ui, sans-serif",
|
||||
elementHeadlineFontSize: "1.125rem",
|
||||
elementHeadlineFontWeight: "600",
|
||||
elementHeadlineColor: "#1e293b",
|
||||
elementDescriptionFontFamily: "system-ui, sans-serif",
|
||||
elementDescriptionFontSize: "0.875rem",
|
||||
elementDescriptionFontWeight: "400",
|
||||
elementDescriptionColor: "#64748b",
|
||||
labelFontFamily: "system-ui, sans-serif",
|
||||
labelFontSize: "0.75rem",
|
||||
labelFontWeight: "400",
|
||||
labelColor: "#64748b",
|
||||
labelOpacity: "1",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontWeight",
|
||||
"inputBorderRadius",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "rating-1",
|
||||
inputId: "rating-input-1",
|
||||
headline: "How satisfied are you?",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "rating-2",
|
||||
inputId: "rating-input-2",
|
||||
headline: "How satisfied are you?",
|
||||
description: "Please rate your experience from 1 to 5",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const NumberScale: Story = {
|
||||
args: {
|
||||
elementId: "rating-number",
|
||||
inputId: "rating-input-number",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const StarScale: Story = {
|
||||
args: {
|
||||
elementId: "rating-star",
|
||||
inputId: "rating-input-star",
|
||||
headline: "Rate this product",
|
||||
scale: "star",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const SmileyScale: Story = {
|
||||
args: {
|
||||
elementId: "rating-smiley",
|
||||
inputId: "rating-input-smiley",
|
||||
headline: "How do you feel?",
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabels: Story = {
|
||||
args: {
|
||||
elementId: "rating-labels",
|
||||
inputId: "rating-input-labels",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: "Not satisfied",
|
||||
upperLabel: "Very satisfied",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
elementId: "rating-selection",
|
||||
inputId: "rating-input-selection",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
value: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "rating-required",
|
||||
inputId: "rating-input-required",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "rating-error",
|
||||
inputId: "rating-input-error",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
required: true,
|
||||
errorMessage: "Please select a rating",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "rating-disabled",
|
||||
inputId: "rating-input-disabled",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
value: 3,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Range3: Story = {
|
||||
args: {
|
||||
elementId: "rating-range3",
|
||||
inputId: "rating-input-range3",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 3,
|
||||
},
|
||||
};
|
||||
|
||||
export const Range10: Story = {
|
||||
args: {
|
||||
elementId: "rating-range10",
|
||||
inputId: "rating-input-range10",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorCoding: Story = {
|
||||
args: {
|
||||
elementId: "rating-color",
|
||||
inputId: "rating-input-color",
|
||||
headline: "How do you feel?",
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
colorCoding: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "rating-rtl",
|
||||
dir: "rtl",
|
||||
inputId: "rating-input-rtl",
|
||||
headline: "كيف تقيم تجربتك؟",
|
||||
description: "يرجى تقييم تجربتك من 1 إلى 5",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: "غير راض",
|
||||
upperLabel: "راض جداً",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelection: Story = {
|
||||
args: {
|
||||
elementId: "rating-rtl-selection",
|
||||
dir: "rtl",
|
||||
inputId: "rating-input-rtl-selection",
|
||||
headline: "كيف تقيم تجربتك؟",
|
||||
scale: "star",
|
||||
range: 5,
|
||||
value: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => {
|
||||
const [value1, setValue1] = React.useState<number | undefined>(undefined);
|
||||
const [value2, setValue2] = React.useState<number | undefined>(undefined);
|
||||
const [value3, setValue3] = React.useState<number | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Rating
|
||||
elementId="rating-1"
|
||||
inputId="rating-input-1"
|
||||
headline="How satisfied are you with our service?"
|
||||
scale="number"
|
||||
range={5}
|
||||
lowerLabel="Not satisfied"
|
||||
upperLabel="Very satisfied"
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
/>
|
||||
<Rating
|
||||
elementId="rating-2"
|
||||
inputId="rating-input-2"
|
||||
headline="Rate this product"
|
||||
description="Please rate from 1 to 5 stars"
|
||||
scale="star"
|
||||
range={5}
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
/>
|
||||
<Rating
|
||||
elementId="rating-3"
|
||||
inputId="rating-input-3"
|
||||
headline="How do you feel?"
|
||||
scale="smiley"
|
||||
range={5}
|
||||
colorCoding
|
||||
value={value3}
|
||||
onChange={setValue3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
442
packages/survey-ui/src/components/elements/rating.tsx
Normal file
442
packages/survey-ui/src/components/elements/rating.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { Star } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Label } from "@/components/general/label";
|
||||
import {
|
||||
ConfusedFace,
|
||||
FrowningFace,
|
||||
GrinningFaceWithSmilingEyes,
|
||||
GrinningSquintingFace,
|
||||
NeutralFace,
|
||||
PerseveringFace,
|
||||
SlightlySmilingFace,
|
||||
SmilingFaceWithSmilingEyes,
|
||||
TiredFace,
|
||||
WearyFace,
|
||||
} from "@/components/general/smileys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Get smiley color class based on range and index
|
||||
*/
|
||||
const getSmileyColor = (range: number, idx: number): string => {
|
||||
if (range > 5) {
|
||||
if (range - idx < 3) return "fill-emerald-100";
|
||||
if (range - idx < 5) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
} else if (range < 5) {
|
||||
if (range - idx < 2) return "fill-emerald-100";
|
||||
if (range - idx < 3) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
}
|
||||
if (range - idx < 3) return "fill-emerald-100";
|
||||
if (range - idx < 4) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active smiley color class based on range and index
|
||||
*/
|
||||
const getActiveSmileyColor = (range: number, idx: number): string => {
|
||||
if (range > 5) {
|
||||
if (range - idx < 3) return "fill-emerald-300";
|
||||
if (range - idx < 5) return "fill-orange-300";
|
||||
return "fill-rose-300";
|
||||
} else if (range < 5) {
|
||||
if (range - idx < 2) return "fill-emerald-300";
|
||||
if (range - idx < 3) return "fill-orange-300";
|
||||
return "fill-rose-300";
|
||||
}
|
||||
if (range - idx < 3) return "fill-emerald-300";
|
||||
if (range - idx < 4) return "fill-orange-300";
|
||||
return "fill-rose-300";
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the appropriate smiley icon based on range and index
|
||||
*/
|
||||
const getSmileyIcon = (
|
||||
iconIdx: number,
|
||||
idx: number,
|
||||
range: number,
|
||||
active: boolean,
|
||||
addColors: boolean
|
||||
): React.JSX.Element => {
|
||||
const activeColor = addColors ? getActiveSmileyColor(range, idx) : "fill-yellow-200";
|
||||
const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none";
|
||||
|
||||
const icons = [
|
||||
<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,
|
||||
}: Readonly<RatingProps>): React.JSX.Element {
|
||||
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
||||
|
||||
// Ensure value is within valid range
|
||||
const currentValue = value && value >= 1 && value <= range ? value : undefined;
|
||||
|
||||
// Handle rating selection
|
||||
const handleSelect = (ratingValue: number): void => {
|
||||
if (!disabled) {
|
||||
onChange(ratingValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (ratingValue: number) => (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect(ratingValue);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
const direction = e.key === "ArrowLeft" ? -1 : 1;
|
||||
const newValue = Math.max(1, Math.min(range, (currentValue ?? 1) + direction));
|
||||
handleSelect(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Get number option color for color coding
|
||||
const getRatingNumberOptionColor = (ratingRange: number, idx: number): string => {
|
||||
if (ratingRange > 5) {
|
||||
if (ratingRange - idx < 2) return "bg-emerald-100";
|
||||
if (ratingRange - idx < 4) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
} else if (ratingRange < 5) {
|
||||
if (ratingRange - idx < 1) return "bg-emerald-100";
|
||||
if (ratingRange - idx < 2) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
}
|
||||
if (ratingRange - idx < 2) return "bg-emerald-100";
|
||||
if (ratingRange - idx < 3) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
};
|
||||
|
||||
// Render number scale option
|
||||
const renderNumberOption = (number: number, totalLength: number): React.JSX.Element => {
|
||||
const isSelected = currentValue === number;
|
||||
const isHovered = hoveredValue === number;
|
||||
const isLast = totalLength === number;
|
||||
const isFirst = number === 1;
|
||||
|
||||
// Determine border radius and border classes
|
||||
// Use right border for all items to create separators, left border only on first item
|
||||
let borderRadiusClasses = "";
|
||||
let borderClasses = "border-t border-b border-r";
|
||||
|
||||
if (isFirst) {
|
||||
borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input";
|
||||
borderClasses = "border-t border-b border-l border-r";
|
||||
} else if (isLast) {
|
||||
borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input";
|
||||
// Last item keeps right border for rounded corner
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
<label
|
||||
key={number}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown(number)}
|
||||
className={cn(
|
||||
"text-input-text font-input font-input-weight relative flex w-full cursor-pointer items-center justify-center overflow-hidden transition-colors focus:outline-none",
|
||||
borderClasses,
|
||||
isSelected
|
||||
? "bg-brand-20 border-brand z-10 -ml-[1px] border-2 first:ml-0"
|
||||
: "border-input-border bg-input-bg",
|
||||
borderRadiusClasses,
|
||||
isHovered && !isSelected && "bg-input-selected-bg",
|
||||
colorCoding ? "min-h-[47px]" : "min-h-[41px]",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
"focus:border-brand focus:border-2"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHoveredValue(null);
|
||||
}}>
|
||||
{colorCoding ? (
|
||||
<div
|
||||
className={cn("absolute top-0 left-0 h-[6px] w-full", getRatingNumberOptionColor(range, number))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={number}
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)}`}
|
||||
/>
|
||||
<span className="text-sm">{number}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// Render star scale option
|
||||
const renderStarOption = (number: number): React.JSX.Element => {
|
||||
const isSelected = currentValue === number;
|
||||
// Fill all stars up to the hovered value (if hovering) or selected value
|
||||
const activeValue = hoveredValue ?? currentValue ?? 0;
|
||||
const isActive = number <= activeValue;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
<label
|
||||
key={number}
|
||||
className={cn(
|
||||
"flex min-h-[48px] flex-1 cursor-pointer items-center justify-center transition-opacity",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown(number)}>
|
||||
<input
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={number}
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)} stars`}
|
||||
/>
|
||||
<div className="pointer-events-none flex w-full max-w-[74px] items-center justify-center">
|
||||
{isActive ? (
|
||||
<Star className="h-full w-full fill-yellow-400 text-yellow-400 transition-colors" />
|
||||
) : (
|
||||
<Star className="h-full w-full fill-slate-300 text-slate-300 transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// Render smiley scale option
|
||||
const renderSmileyOption = (number: number, index: number): React.JSX.Element => {
|
||||
const isSelected = currentValue === number;
|
||||
const isHovered = hoveredValue === number;
|
||||
const isActive = isSelected || isHovered;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
<label
|
||||
key={number}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown(number)}
|
||||
className={cn(
|
||||
"relative flex max-h-16 min-h-9 w-full cursor-pointer justify-center transition-colors focus:outline-none",
|
||||
isActive
|
||||
? "stroke-brand text-brand"
|
||||
: "stroke-muted-foreground text-muted-foreground focus:border-accent focus:border-2",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHoveredValue(null);
|
||||
}}>
|
||||
<input
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={number}
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)}`}
|
||||
/>
|
||||
<div className="text-input-text pointer-events-none h-full w-full max-w-[74px] object-contain">
|
||||
<RatingSmiley active={isActive} idx={index} range={range} addColors={colorCoding} />
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// Generate rating options
|
||||
const ratingOptions = Array.from({ length: range }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Rating Options */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<fieldset className="w-full">
|
||||
<legend className="sr-only">Rating options</legend>
|
||||
<div className="flex w-full px-[2px]">
|
||||
{ratingOptions.map((number, index) => {
|
||||
if (scale === "number") {
|
||||
return renderNumberOption(number, ratingOptions.length);
|
||||
} else if (scale === "star") {
|
||||
return renderStarOption(number);
|
||||
}
|
||||
return renderSmileyOption(number, index);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{(lowerLabel ?? upperLabel) ? (
|
||||
<div className="mt-4 flex justify-between gap-8 px-1.5">
|
||||
{lowerLabel ? (
|
||||
<Label variant="default" className="max-w-[50%] text-xs leading-6" dir={dir}>
|
||||
{lowerLabel}
|
||||
</Label>
|
||||
) : null}
|
||||
{upperLabel ? (
|
||||
<Label variant="default" className="max-w-[50%] text-right text-xs leading-6" dir={dir}>
|
||||
{upperLabel}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Rating };
|
||||
export type { RatingProps };
|
||||
@@ -0,0 +1,382 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
type OptionStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
optionStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { SingleSelect, type SingleSelectOption, type SingleSelectProps } from "./single-select";
|
||||
|
||||
type StoryProps = SingleSelectProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & OptionStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/SingleSelect",
|
||||
component: SingleSelect,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete single-select element that combines headline, description, and radio button options. Supports single selection, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of options to choose from",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Selected option ID",
|
||||
table: { category: "State" },
|
||||
},
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["list", "dropdown"],
|
||||
description: "Display variant: 'list' shows radio buttons, 'dropdown' shows a dropdown menu",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for dropdown button when no option is selected",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: function Render(args: StoryProps) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
const [otherValue, setOtherValue] = useState(args.otherValue);
|
||||
const handleOtherValueChange = (v: string) => {
|
||||
setOtherValue(v);
|
||||
args.onOtherValueChange?.(v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<SingleSelect
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={handleOtherValueChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
const defaultOptions: SingleSelectOption[] = [
|
||||
{ id: "option-1", label: "Option 1" },
|
||||
{ id: "option-2", label: "Option 2" },
|
||||
{ id: "option-3", label: "Option 3" },
|
||||
{ id: "option-4", label: "Option 4" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
description: "Select one option",
|
||||
options: defaultOptions,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...optionStylingArgTypes,
|
||||
...inputStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "What is your favorite programming language?",
|
||||
description: "Select the language you use most frequently",
|
||||
options: [
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
{ id: "java", label: "Java" },
|
||||
{ id: "go", label: "Go" },
|
||||
{ id: "rust", label: "Rust" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Select your preferred plan",
|
||||
description: "Please choose one option",
|
||||
options: [
|
||||
{ id: "basic", label: "Basic Plan" },
|
||||
{ id: "pro", label: "Pro Plan" },
|
||||
{ id: "enterprise", label: "Enterprise Plan" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
description: "Select one option",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Select your preference",
|
||||
description: "Please select an option",
|
||||
options: [
|
||||
{ id: "yes", label: "Yes" },
|
||||
{ id: "no", label: "No" },
|
||||
{ id: "maybe", label: "Maybe" },
|
||||
],
|
||||
errorMessage: "Please select an option",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو خيارك المفضل؟",
|
||||
dir: "rtl",
|
||||
description: "اختر خيارًا واحدًا",
|
||||
options: [
|
||||
{ id: "opt-1", label: "الخيار الأول" },
|
||||
{ id: "opt-2", label: "الخيار الثاني" },
|
||||
{ id: "opt-3", label: "الخيار الثالث" },
|
||||
{ id: "opt-4", label: "الخيار الرابع" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelection: Story = {
|
||||
args: {
|
||||
headline: "ما هو تفضيلك؟",
|
||||
dir: "rtl",
|
||||
description: "يرجى اختيار خيار واحد",
|
||||
options: [
|
||||
{ id: "tech", label: "التكنولوجيا" },
|
||||
{ id: "design", label: "التصميم" },
|
||||
{ id: "marketing", label: "التسويق" },
|
||||
{ id: "sales", label: "المبيعات" },
|
||||
],
|
||||
value: "tech",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => {
|
||||
const [value1, setValue1] = React.useState<string | undefined>(undefined);
|
||||
const [value2, setValue2] = React.useState<string>("js");
|
||||
|
||||
return (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<SingleSelect
|
||||
elementId="preference"
|
||||
inputId="preference-input"
|
||||
headline="Which option do you prefer?"
|
||||
description="Select one option"
|
||||
options={defaultOptions}
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
/>
|
||||
<SingleSelect
|
||||
elementId="language"
|
||||
inputId="language-input"
|
||||
headline="What is your favorite programming language?"
|
||||
options={[
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
]}
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Dropdown: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
description: "Select one option",
|
||||
options: defaultOptions,
|
||||
variant: "dropdown",
|
||||
placeholder: "Choose an option...",
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithSelection: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
description: "Select one option",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
variant: "dropdown",
|
||||
placeholder: "Choose an option...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string | undefined>(undefined);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<SingleSelect
|
||||
elementId="single-select-other"
|
||||
inputId="single-select-other-input"
|
||||
headline="Which option do you prefer?"
|
||||
description="Select one option"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOptionSelected: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string>("other");
|
||||
const [otherValue, setOtherValue] = React.useState<string>("Custom option");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<SingleSelect
|
||||
elementId="single-select-other-selected"
|
||||
inputId="single-select-other-selected-input"
|
||||
headline="Which option do you prefer?"
|
||||
description="Select one option"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string | undefined>(undefined);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<SingleSelect
|
||||
elementId="single-select-dropdown-other"
|
||||
inputId="single-select-dropdown-other-input"
|
||||
headline="Which option do you prefer?"
|
||||
description="Select one option"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variant="dropdown"
|
||||
placeholder="Choose an option..."
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithContainerStyling: Story = {
|
||||
args: {
|
||||
headline: "Select your preferred option",
|
||||
description: "Each option has a container with custom styling",
|
||||
options: [
|
||||
{ id: "option-1", label: "Option 1" },
|
||||
{ id: "option-2", label: "Option 2" },
|
||||
{ id: "option-3", label: "Option 3" },
|
||||
{ id: "option-4", label: "Option 4" },
|
||||
],
|
||||
value: "option-2",
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const WithContainerStylingAndOther: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string | undefined>(undefined);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<SingleSelect
|
||||
elementId="container-styling-other"
|
||||
inputId="container-styling-other-input"
|
||||
headline="Select an option"
|
||||
description="Options have containers, including the 'Other' option"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
315
packages/survey-ui/src/components/elements/single-select.tsx
Normal file
315
packages/survey-ui/src/components/elements/single-select.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/general/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/general/dropdown-menu";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/general/radio-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Option for single-select element
|
||||
*/
|
||||
export interface SingleSelectOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SingleSelectProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the single-select group */
|
||||
inputId: string;
|
||||
/** Array of options to choose from */
|
||||
options: SingleSelectOption[];
|
||||
/** Currently selected option ID */
|
||||
value?: string;
|
||||
/** Callback function called when selection changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display below the options */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
/** Display variant: 'list' shows radio buttons, 'dropdown' shows a dropdown menu */
|
||||
variant?: "list" | "dropdown";
|
||||
/** Placeholder text for dropdown button when no option is selected */
|
||||
placeholder?: string;
|
||||
/** ID for the 'other' option that allows custom input */
|
||||
otherOptionId?: string;
|
||||
/** Label for the 'other' option */
|
||||
otherOptionLabel?: string;
|
||||
/** Placeholder text for the 'other' input field */
|
||||
otherOptionPlaceholder?: string;
|
||||
/** Custom value entered in the 'other' input field */
|
||||
otherValue?: string;
|
||||
/** Callback when the 'other' input value changes */
|
||||
onOtherValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function SingleSelect({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
variant = "list",
|
||||
placeholder = "Select an option...",
|
||||
otherOptionId,
|
||||
otherOptionLabel = "Other",
|
||||
otherOptionPlaceholder = "Please specify",
|
||||
otherValue = "",
|
||||
onOtherValueChange,
|
||||
}: Readonly<SingleSelectProps>): React.JSX.Element {
|
||||
// Ensure value is always a string or undefined
|
||||
const selectedValue = value ?? undefined;
|
||||
const hasOtherOption = Boolean(otherOptionId);
|
||||
const isOtherSelected = hasOtherOption && selectedValue === otherOptionId;
|
||||
const otherInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOtherSelected || disabled) return;
|
||||
|
||||
// Delay focus to win against Radix focus restoration when dropdown closes / radio item receives focus.
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
globalThis.requestAnimationFrame(() => {
|
||||
otherInputRef.current?.focus();
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isOtherSelected, disabled, variant]);
|
||||
|
||||
const handleOtherInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
onOtherValueChange?.(e.target.value);
|
||||
};
|
||||
|
||||
// Shared className for option containers
|
||||
const getOptionContainerClassName = (isSelected: boolean): string =>
|
||||
cn(
|
||||
"relative flex cursor-pointer flex-col border transition-colors outline-none",
|
||||
"rounded-option px-option-x py-option-y",
|
||||
isSelected ? "bg-option-selected-bg border-brand" : "bg-option-bg border-option-border",
|
||||
"focus-within:border-brand focus-within:bg-option-selected-bg",
|
||||
"hover:bg-option-hover-bg",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
);
|
||||
|
||||
// Shared className for option labels
|
||||
const optionLabelClassName = "font-option font-option-weight text-option-label";
|
||||
|
||||
// Get selected option label for dropdown display
|
||||
const selectedOption = options.find((opt) => opt.id === selectedValue);
|
||||
const displayText = isOtherSelected
|
||||
? otherValue || otherOptionLabel
|
||||
: (selectedOption?.label ?? placeholder);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3">
|
||||
{variant === "dropdown" ? (
|
||||
<>
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="rounded-input w-full justify-between"
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
aria-label={headline}>
|
||||
<span className="truncate">{displayText}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)]"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
|
||||
{options
|
||||
.filter((option) => option.id !== "none")
|
||||
.map((option) => {
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{option.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<DropdownMenuRadioItem
|
||||
value={otherOptionId}
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{otherValue || otherOptionLabel}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
) : null}
|
||||
{options
|
||||
.filter((option) => option.id === "none")
|
||||
.map((option) => {
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{option.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isOtherSelected ? (
|
||||
<Input
|
||||
ref={otherInputRef}
|
||||
type="text"
|
||||
value={otherValue}
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
className="w-full"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<RadioGroup
|
||||
name={inputId}
|
||||
value={selectedValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage}
|
||||
required={required}
|
||||
className="w-full gap-0 space-y-2">
|
||||
{options
|
||||
.filter((option) => option.id !== "none")
|
||||
.map((option) => {
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
const isSelected = selectedValue === option.id;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<label
|
||||
htmlFor={`${inputId}-${otherOptionId}`}
|
||||
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<RadioGroupItem
|
||||
value={otherOptionId}
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{otherOptionLabel}
|
||||
</span>
|
||||
</span>
|
||||
{isOtherSelected ? (
|
||||
<Input
|
||||
ref={otherInputRef}
|
||||
type="text"
|
||||
value={otherValue}
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
dir={dir}
|
||||
className="mt-2 w-full"
|
||||
required={required}
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
) : null}
|
||||
{options
|
||||
.filter((option) => option.id === "none")
|
||||
.map((option) => {
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
const isSelected = selectedValue === option.id;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SingleSelect };
|
||||
export type { SingleSelectProps };
|
||||
66
packages/survey-ui/src/components/general/alert.stories.tsx
Normal file
66
packages/survey-ui/src/components/general/alert.stories.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type Meta, type StoryObj } from "@storybook/react";
|
||||
import { TriangleAlertIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./alert";
|
||||
|
||||
const meta: Meta<typeof Alert> = {
|
||||
title: "UI-package/General/Alert",
|
||||
component: Alert,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive"],
|
||||
description: "Style variant of the alert",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Alert>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Alert>
|
||||
<AlertTitle>Alert Title</AlertTitle>
|
||||
<AlertDescription>This is a default alert message.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
render: () => (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Something went wrong. Please try again.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
};
|
||||
|
||||
export const DestructiveWithIcon: Story = {
|
||||
render: () => (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Something went wrong. Please try again.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTitleOnly: Story = {
|
||||
render: () => (
|
||||
<Alert>
|
||||
<AlertTitle>Important Notice</AlertTitle>
|
||||
</Alert>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithDescriptionOnly: Story = {
|
||||
render: () => (
|
||||
<Alert>
|
||||
<AlertDescription>This alert only has a description.</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
};
|
||||
54
packages/survey-ui/src/components/general/alert.tsx
Normal file
54
packages/survey-ui/src/components/general/alert.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>): React.JSX.Element {
|
||||
return (
|
||||
<div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
243
packages/survey-ui/src/components/general/button.stories.tsx
Normal file
243
packages/survey-ui/src/components/general/button.stories.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import type { Decorator, Meta, StoryContext, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Button } from "./button";
|
||||
|
||||
// Styling options for the StylingPlayground story
|
||||
interface StylingOptions {
|
||||
buttonHeight: string;
|
||||
buttonWidth: string;
|
||||
buttonFontSize: string;
|
||||
buttonFontFamily: string;
|
||||
buttonFontWeight: string;
|
||||
buttonBorderRadius: string;
|
||||
buttonBgColor: string;
|
||||
buttonTextColor: string;
|
||||
buttonPaddingX: string;
|
||||
buttonPaddingY: string;
|
||||
}
|
||||
|
||||
type ButtonProps = React.ComponentProps<typeof Button>;
|
||||
type StoryProps = ButtonProps & StylingOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/General/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"],
|
||||
description: "Visual style variant of the button",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["default", "sm", "lg", "icon"],
|
||||
description: "Size of the button",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
asChild: {
|
||||
table: { disable: true },
|
||||
},
|
||||
children: {
|
||||
table: { disable: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
const withCSSVariables: Decorator<StoryProps> = (
|
||||
Story: React.ComponentType,
|
||||
context: StoryContext<StoryProps>
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
|
||||
const args = context.args as StoryProps;
|
||||
const {
|
||||
buttonHeight,
|
||||
buttonWidth,
|
||||
buttonFontSize,
|
||||
buttonFontFamily,
|
||||
buttonFontWeight,
|
||||
buttonBorderRadius,
|
||||
buttonBgColor,
|
||||
buttonTextColor,
|
||||
buttonPaddingX,
|
||||
buttonPaddingY,
|
||||
} = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||
"--fb-button-height": buttonHeight,
|
||||
"--fb-button-width": buttonWidth,
|
||||
"--fb-button-font-size": buttonFontSize,
|
||||
"--fb-button-font-family": buttonFontFamily,
|
||||
"--fb-button-font-weight": buttonFontWeight,
|
||||
"--fb-button-border-radius": buttonBorderRadius,
|
||||
"--fb-button-bg-color": buttonBgColor,
|
||||
"--fb-button-text-color": buttonTextColor,
|
||||
"--fb-button-padding-x": buttonPaddingX,
|
||||
"--fb-button-padding-y": buttonPaddingY,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cssVarStyle}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
variant: "custom",
|
||||
children: "Custom Button",
|
||||
},
|
||||
argTypes: {
|
||||
// Button Styling (CSS Variables) - Only for this story
|
||||
buttonHeight: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonWidth: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonFontSize: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonFontFamily: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonFontWeight: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonBorderRadius: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonBgColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonTextColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonPaddingX: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonPaddingY: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withCSSVariables],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
variant: "destructive",
|
||||
children: "Delete",
|
||||
},
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
variant: "outline",
|
||||
children: "Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: "secondary",
|
||||
children: "Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
variant: "ghost",
|
||||
children: "Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Link: Story = {
|
||||
args: {
|
||||
variant: "link",
|
||||
children: "Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: "sm",
|
||||
children: "Small Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: "lg",
|
||||
children: "Large Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Icon: Story = {
|
||||
args: {
|
||||
size: "icon",
|
||||
children: "×",
|
||||
},
|
||||
};
|
||||
|
||||
export const Custom: Story = {
|
||||
args: {
|
||||
variant: "custom",
|
||||
children: "Custom Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
children: "Disabled Button",
|
||||
},
|
||||
};
|
||||
58
packages/survey-ui/src/components/general/button.tsx
Normal file
58
packages/survey-ui/src/components/general/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
custom: "button-custom",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
218
packages/survey-ui/src/components/general/calendar.tsx
Normal file
218
packages/survey-ui/src/components/general/calendar.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { type Locale, format } from "date-fns";
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { type DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import { Button, buttonVariants } from "@/components/general/button";
|
||||
import { getDateFnsLocale } from "@/lib/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Extracted components to avoid defining during render
|
||||
function CalendarRoot({
|
||||
className,
|
||||
rootRef,
|
||||
...props
|
||||
}: Readonly<{
|
||||
className?: string;
|
||||
rootRef?: React.Ref<HTMLDivElement>;
|
||||
children?: React.ReactNode;
|
||||
}>): React.JSX.Element {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function CalendarChevron({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: Readonly<{
|
||||
className?: string;
|
||||
orientation?: "left" | "right" | "up" | "down";
|
||||
}>): React.JSX.Element {
|
||||
if (orientation === "left") {
|
||||
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CalendarWeekNumber({
|
||||
children,
|
||||
...props
|
||||
}: Readonly<{ children?: React.ReactNode }>): React.JSX.Element {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex h-[--cell-size] w-[--cell-size] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
locale,
|
||||
...props
|
||||
}: Readonly<
|
||||
React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
locale?: Locale | string;
|
||||
}
|
||||
>): React.JSX.Element {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
// Resolve locale to Locale object if string is provided
|
||||
const resolvedLocale = React.useMemo(() => {
|
||||
if (!locale) return undefined;
|
||||
if (typeof locale === "string") {
|
||||
return getDateFnsLocale(locale);
|
||||
}
|
||||
return locale;
|
||||
}, [locale]);
|
||||
|
||||
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={{
|
||||
// @ts-expect-error - React types version mismatch - the project uses React 19 types, but some Radix UI packages (react-day-picker) bundle their own older React types, creating incompatible Ref type definitions
|
||||
Root: CalendarRoot,
|
||||
Chevron: CalendarChevron,
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: CalendarWeekNumber,
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>): React.JSX.Element {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end ? !modifiers.range_middle : null
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Checkbox } from "./checkbox";
|
||||
import { Label } from "./label";
|
||||
|
||||
const meta: Meta<typeof Checkbox> = {
|
||||
title: "UI-package/General/Checkbox",
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A checkbox component built with Radix UI primitives. Supports checked, unchecked, and indeterminate states with full accessibility support.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
checked: {
|
||||
control: { type: "boolean" },
|
||||
description: "The controlled checked state of the checkbox",
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "Whether the checkbox is disabled",
|
||||
},
|
||||
required: {
|
||||
control: { type: "boolean" },
|
||||
description: "Whether the checkbox is required",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
"aria-label": "Checkbox",
|
||||
},
|
||||
};
|
||||
|
||||
export const Checked: Story = {
|
||||
args: {
|
||||
checked: true,
|
||||
"aria-label": "Checked checkbox",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
"aria-label": "Disabled checkbox",
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledChecked: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
checked: true,
|
||||
"aria-label": "Disabled checked checkbox",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" />
|
||||
<Label htmlFor="terms">Accept terms and conditions</Label>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLabelChecked: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms-checked" checked />
|
||||
<Label htmlFor="terms-checked">Accept terms and conditions</Label>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLabelDisabled: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms-disabled" disabled />
|
||||
<Label htmlFor="terms-disabled">Accept terms and conditions</Label>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
27
packages/survey-ui/src/components/general/checkbox.tsx
Normal file
27
packages/survey-ui/src/components/general/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>): React.JSX.Element {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"border-input-border dark:bg-input/30 data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground dark:data-[state=checked]:bg-brand data-[state=checked]:border-brand focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-input-text peer size-4 shrink-0 rounded-[4px] border bg-white shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="text-brand-foreground flex items-center justify-center text-current transition-none">
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
218
packages/survey-ui/src/components/general/dropdown-menu.tsx
Normal file
218
packages/survey-ui/src/components/general/dropdown-menu.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({ ...props }: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Root>>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Portal>>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Content>>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Sub>>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
36
packages/survey-ui/src/components/general/element-error.tsx
Normal file
36
packages/survey-ui/src/components/general/element-error.tsx
Normal 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" }: Readonly<ElementErrorProps>): React.JSX.Element | null {
|
||||
if (!errorMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Error indicator bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-destructive absolute top-0 bottom-0 w-[4px]",
|
||||
dir === "rtl" ? "right-[-10px]" : "left-[-10px]"
|
||||
)}
|
||||
/>
|
||||
{/* Error message - shown at top */}
|
||||
<div className="text-destructive mb-2 flex items-center gap-1 text-sm" dir={dir}>
|
||||
<AlertCircle className="size-4" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElementError };
|
||||
export type { ElementErrorProps };
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
102
packages/survey-ui/src/components/general/element-header.tsx
Normal file
102
packages/survey-ui/src/components/general/element-header.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import * as React from "react";
|
||||
import { ElementMedia } from "@/components/general/element-media";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { cn, stripInlineStyles } from "@/lib/utils";
|
||||
|
||||
interface ElementHeaderProps extends React.ComponentProps<"div"> {
|
||||
headline: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
htmlFor?: string;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
imageAltText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string contains valid HTML markup
|
||||
* @param str - The input string to test
|
||||
* @returns true if the string contains valid HTML elements, false otherwise
|
||||
*/
|
||||
const isValidHTML = (str: string): boolean => {
|
||||
if (!str) return false;
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(str, "text/html");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
if (errorNode) return false;
|
||||
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips inline style attributes to prevent CSP violations
|
||||
* Uses a safe regex pattern to avoid ReDoS (Regular Expression Denial of Service) vulnerabilities
|
||||
* @param html - The HTML string to clean
|
||||
* @returns HTML string without inline style attributes
|
||||
*/
|
||||
|
||||
function ElementHeader({
|
||||
headline,
|
||||
description,
|
||||
required = false,
|
||||
htmlFor,
|
||||
className,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
imageAltText,
|
||||
...props
|
||||
}: Readonly<ElementHeaderProps>): React.JSX.Element {
|
||||
const isMediaAvailable = imageUrl ?? videoUrl;
|
||||
|
||||
// Check if headline is HTML
|
||||
const strippedHeadline = stripInlineStyles(headline);
|
||||
const isHeadlineHtml = isValidHTML(strippedHeadline);
|
||||
const safeHeadlineHtml =
|
||||
isHeadlineHtml && strippedHeadline
|
||||
? DOMPurify.sanitize(strippedHeadline, {
|
||||
ADD_ATTR: ["target"],
|
||||
FORBID_ATTR: ["style"],
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
{/* Media (Image or Video) */}
|
||||
{isMediaAvailable ? (
|
||||
<ElementMedia imgUrl={imageUrl} videoUrl={videoUrl} altText={imageAltText} />
|
||||
) : null}
|
||||
|
||||
{/* Headline */}
|
||||
<div>
|
||||
<div>
|
||||
{required ? <span className="label-headline mb-[3px] text-xs opacity-60">Required</span> : null}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{isHeadlineHtml && safeHeadlineHtml ? (
|
||||
<Label htmlFor={htmlFor} variant="headline">
|
||||
{headline}
|
||||
</Label>
|
||||
) : (
|
||||
<Label htmlFor={htmlFor} variant="headline" className="font-semibold">
|
||||
{headline}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description/Subheader */}
|
||||
{description ? (
|
||||
<Label htmlFor={htmlFor} variant="description">
|
||||
{description}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElementHeader };
|
||||
export type { ElementHeaderProps };
|
||||
102
packages/survey-ui/src/components/general/element-media.tsx
Normal file
102
packages/survey-ui/src/components/general/element-media.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Download, ExternalLink } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video";
|
||||
|
||||
// Function to add extra params to videoUrls in order to reduce video controls
|
||||
const getVideoUrlWithParams = (videoUrl: string): string | undefined => {
|
||||
// First convert to embed URL
|
||||
const embedUrl = convertToEmbedUrl(videoUrl);
|
||||
if (!embedUrl) return undefined;
|
||||
|
||||
const isYoutubeVideo = checkForYoutubeUrl(videoUrl);
|
||||
const isVimeoUrl = checkForVimeoUrl(videoUrl);
|
||||
const isLoomUrl = checkForLoomUrl(videoUrl);
|
||||
|
||||
if (isYoutubeVideo) {
|
||||
// For YouTube, add parameters to embed URL
|
||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
||||
return `${embedUrl}${separator}controls=0`;
|
||||
} else if (isVimeoUrl) {
|
||||
// For Vimeo, add parameters to embed URL
|
||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
||||
return `${embedUrl}${separator}title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false`;
|
||||
} else if (isLoomUrl) {
|
||||
// For Loom, add parameters to embed URL
|
||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
||||
return `${embedUrl}${separator}hide_share=true&hideEmbedTopBar=true&hide_title=true`;
|
||||
}
|
||||
return embedUrl;
|
||||
};
|
||||
|
||||
interface ElementMediaProps {
|
||||
imgUrl?: string;
|
||||
videoUrl?: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
function ElementMedia({
|
||||
imgUrl,
|
||||
videoUrl,
|
||||
altText = "Image",
|
||||
}: Readonly<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"
|
||||
style={{ border: 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 ?? convertToEmbedUrl(videoUrl ?? "")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open in new tab"
|
||||
className="bg-opacity-40 hover:bg-opacity-65 absolute right-2 bottom-2 flex items-center gap-2 rounded-md bg-slate-800 p-1.5 text-white opacity-0 backdrop-blur-lg transition duration-300 ease-in-out group-hover/image:opacity-100">
|
||||
{imgUrl ? <Download size={20} /> : <ExternalLink size={20} />}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElementMedia };
|
||||
export type { ElementMediaProps };
|
||||
294
packages/survey-ui/src/components/general/input.stories.tsx
Normal file
294
packages/survey-ui/src/components/general/input.stories.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import type { Decorator, Meta, StoryContext, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Input, type InputProps } from "./input";
|
||||
|
||||
// Styling options for the StylingPlayground story
|
||||
interface StylingOptions {
|
||||
inputWidth: string;
|
||||
inputHeight: string;
|
||||
inputBgColor: string;
|
||||
inputBorderColor: string;
|
||||
inputBorderRadius: string;
|
||||
inputFontFamily: string;
|
||||
inputFontSize: string;
|
||||
inputFontWeight: string;
|
||||
inputColor: string;
|
||||
inputPlaceholderColor: string;
|
||||
inputPaddingX: string;
|
||||
inputPaddingY: string;
|
||||
inputShadow: string;
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
type StoryProps = InputProps & Partial<StylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/General/Input",
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
type: {
|
||||
control: { type: "select" },
|
||||
options: ["text", "email", "password", "number", "tel", "url", "search", "file"],
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
errorMessage: {
|
||||
control: "text",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
dir: {
|
||||
control: { type: "select" },
|
||||
options: ["ltr", "rtl"],
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
const withCSSVariables: Decorator<StoryProps> = (
|
||||
Story: React.ComponentType,
|
||||
context: StoryContext<StoryProps>
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
|
||||
const args = context.args as StoryProps;
|
||||
const {
|
||||
inputWidth,
|
||||
inputHeight,
|
||||
inputBgColor,
|
||||
inputBorderColor,
|
||||
inputBorderRadius,
|
||||
inputFontFamily,
|
||||
inputFontSize,
|
||||
inputFontWeight,
|
||||
inputColor,
|
||||
inputPlaceholderColor,
|
||||
inputPaddingX,
|
||||
inputPaddingY,
|
||||
inputShadow,
|
||||
brandColor,
|
||||
} = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||
"--fb-input-width": inputWidth,
|
||||
"--fb-input-height": inputHeight,
|
||||
"--fb-input-bg-color": inputBgColor,
|
||||
"--fb-input-border-color": inputBorderColor,
|
||||
"--fb-input-border-radius": inputBorderRadius,
|
||||
"--fb-input-font-family": inputFontFamily,
|
||||
"--fb-input-font-size": inputFontSize,
|
||||
"--fb-input-font-weight": inputFontWeight,
|
||||
"--fb-input-color": inputColor,
|
||||
"--fb-input-placeholder-color": inputPlaceholderColor,
|
||||
"--fb-input-padding-x": inputPaddingX,
|
||||
"--fb-input-padding-y": inputPaddingY,
|
||||
"--fb-input-shadow": inputShadow,
|
||||
"--fb-survey-brand-color": brandColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cssVarStyle}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
placeholder: "Enter text...",
|
||||
},
|
||||
argTypes: {
|
||||
// Input Styling (CSS Variables) - Only for this story
|
||||
inputWidth: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "100%" },
|
||||
},
|
||||
},
|
||||
inputHeight: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "2.25rem" },
|
||||
},
|
||||
},
|
||||
inputBgColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "transparent" },
|
||||
},
|
||||
},
|
||||
inputBorderColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "var(--input)" },
|
||||
},
|
||||
},
|
||||
inputBorderRadius: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "0.5rem" },
|
||||
},
|
||||
},
|
||||
inputFontFamily: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "inherit" },
|
||||
},
|
||||
},
|
||||
inputFontSize: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "0.875rem" },
|
||||
},
|
||||
},
|
||||
inputFontWeight: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "400" },
|
||||
},
|
||||
},
|
||||
inputColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "var(--foreground)" },
|
||||
},
|
||||
},
|
||||
inputPlaceholderColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "var(--muted-foreground)" },
|
||||
},
|
||||
},
|
||||
inputPaddingX: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "0.75rem" },
|
||||
},
|
||||
},
|
||||
inputPaddingY: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "0.25rem" },
|
||||
},
|
||||
},
|
||||
inputShadow: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "0 1px 2px 0 rgb(0 0 0 / 0.05)" },
|
||||
},
|
||||
},
|
||||
brandColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "var(--fb-survey-brand-color)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withCSSVariables],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
placeholder: "Enter text...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
defaultValue: "Sample text",
|
||||
placeholder: "Enter text...",
|
||||
},
|
||||
};
|
||||
|
||||
export const Email: Story = {
|
||||
args: {
|
||||
type: "email",
|
||||
placeholder: "email@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
export const Password: Story = {
|
||||
args: {
|
||||
type: "password",
|
||||
placeholder: "Enter password",
|
||||
},
|
||||
};
|
||||
|
||||
export const NumberInput: Story = {
|
||||
args: {
|
||||
type: "number",
|
||||
placeholder: "0",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
placeholder: "Enter your email",
|
||||
defaultValue: "invalid-email",
|
||||
errorMessage: "Please enter a valid email address",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
placeholder: "Disabled input",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledWithValue: Story = {
|
||||
args: {
|
||||
defaultValue: "Cannot edit this",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
dir: "rtl",
|
||||
placeholder: "أدخل النص هنا",
|
||||
defaultValue: "نص تجريبي",
|
||||
},
|
||||
};
|
||||
|
||||
export const FullWidth: Story = {
|
||||
args: {
|
||||
placeholder: "Full width input",
|
||||
className: "w-96",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithErrorAndRTL: Story = {
|
||||
args: {
|
||||
dir: "rtl",
|
||||
placeholder: "أدخل بريدك الإلكتروني",
|
||||
errorMessage: "هذا الحقل مطلوب",
|
||||
},
|
||||
};
|
||||
61
packages/survey-ui/src/components/general/input.tsx
Normal file
61
packages/survey-ui/src/components/general/input.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InputProps extends React.ComponentProps<"input"> {
|
||||
/** Text direction for RTL language support */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Error message to display above the input */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ className, type, errorMessage, dir, ...props },
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
dir={dir}
|
||||
data-slot="input"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn(
|
||||
// Layout and behavior
|
||||
"flex min-w-0 border transition-[color,box-shadow] outline-none",
|
||||
// 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-text",
|
||||
"px-input-x py-input-y",
|
||||
"shadow-input",
|
||||
// Placeholder styling
|
||||
"placeholder:opacity-input-placeholder",
|
||||
"placeholder:text-input-placeholder placeholder:text-sm",
|
||||
|
||||
// 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>
|
||||
);
|
||||
});
|
||||
|
||||
export { Input };
|
||||
export type { InputProps };
|
||||
405
packages/survey-ui/src/components/general/label.stories.tsx
Normal file
405
packages/survey-ui/src/components/general/label.stories.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import type { Decorator, Meta, StoryContext, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Checkbox } from "./checkbox";
|
||||
import { Input } from "./input";
|
||||
import { Label, type LabelProps } from "./label";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
// Styling options for the StylingPlayground stories
|
||||
interface HeadlineStylingOptions {
|
||||
headlineFontFamily: string;
|
||||
headlineFontWeight: string;
|
||||
headlineFontSize: string;
|
||||
headlineColor: string;
|
||||
headlineOpacity: string;
|
||||
}
|
||||
|
||||
interface DescriptionStylingOptions {
|
||||
descriptionFontFamily: string;
|
||||
descriptionFontWeight: string;
|
||||
descriptionFontSize: string;
|
||||
descriptionColor: string;
|
||||
descriptionOpacity: string;
|
||||
}
|
||||
|
||||
interface DefaultStylingOptions {
|
||||
labelFontFamily: string;
|
||||
labelFontWeight: string;
|
||||
labelFontSize: string;
|
||||
labelColor: string;
|
||||
labelOpacity: string;
|
||||
}
|
||||
|
||||
type StoryProps = LabelProps &
|
||||
Partial<HeadlineStylingOptions & DescriptionStylingOptions & DefaultStylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/General/Label",
|
||||
component: Label,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A label component built with Radix UI primitives. Provides accessible labeling for form controls with proper association and styling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "headline", "description"],
|
||||
description: "Visual style variant of the label",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
htmlFor: {
|
||||
control: { type: "text" },
|
||||
description: "The id of the form control this label is associated with",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
style: {
|
||||
control: "object",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
},
|
||||
args: {
|
||||
children: "Label text",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables for headline variant
|
||||
const withHeadlineCSSVariables: Decorator<StoryProps> = (
|
||||
Story: React.ComponentType,
|
||||
context: StoryContext<StoryProps>
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
|
||||
const args = context.args as StoryProps;
|
||||
const { headlineFontFamily, headlineFontWeight, headlineFontSize, headlineColor, headlineOpacity } = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | 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 (
|
||||
<div style={cssVarStyle}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Decorator to apply CSS variables for description variant
|
||||
const withDescriptionCSSVariables: Decorator<StoryProps> = (
|
||||
Story: React.ComponentType,
|
||||
context: StoryContext<StoryProps>
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
|
||||
const args = context.args as StoryProps;
|
||||
const {
|
||||
descriptionFontFamily,
|
||||
descriptionFontWeight,
|
||||
descriptionFontSize,
|
||||
descriptionColor,
|
||||
descriptionOpacity,
|
||||
} = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | 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 (
|
||||
<div style={cssVarStyle}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const withCustomCSSVariables: Decorator<StoryProps> = (
|
||||
Story: React.ComponentType,
|
||||
context: StoryContext<StoryProps>
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
|
||||
const args = context.args as StoryProps;
|
||||
const { labelFontFamily, labelFontWeight, labelFontSize, labelColor, labelOpacity } = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||
"--fb-label-font-family": labelFontFamily ?? undefined,
|
||||
"--fb-label-font-weight": labelFontWeight ?? undefined,
|
||||
"--fb-label-font-size": labelFontSize ?? undefined,
|
||||
"--fb-label-color": labelColor ?? undefined,
|
||||
"--fb-label-opacity": labelOpacity ?? undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cssVarStyle}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const WithInput: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" placeholder="Enter your username..." />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithCheckbox: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="terms" />
|
||||
<Label htmlFor="terms">I agree to the terms and conditions</Label>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTextarea: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea id="message" placeholder="Enter your message..." />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
Email address <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input id="email" type="email" placeholder="Enter your email..." required />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Optional: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">
|
||||
Website <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input id="website" type="url" placeholder="https://..." />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithHelpText: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" type="password" placeholder="Enter your password..." />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Must be at least 8 characters with a mix of letters and numbers
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const ErrorState: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invalid-email">
|
||||
Email address <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="invalid-email"
|
||||
type="email"
|
||||
aria-invalid
|
||||
value="invalid-email"
|
||||
placeholder="Enter your email..."
|
||||
/>
|
||||
<p className="text-destructive text-sm">Please enter a valid email address</p>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const FormSection: Story = {
|
||||
render: () => (
|
||||
<div className="w-[300px] space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Personal Information</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first-name">First name</Label>
|
||||
<Input id="first-name" placeholder="Enter your first name..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last-name">Last name</Label>
|
||||
<Input id="last-name" placeholder="Enter your last name..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="birth-date">Date of birth</Label>
|
||||
<Input id="birth-date" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Contact Information</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-email">
|
||||
Email address <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input id="contact-email" type="email" placeholder="Enter your email..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">
|
||||
Phone number <span className="text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input id="phone" type="tel" placeholder="Enter your phone number..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold">Preferences</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="newsletter" />
|
||||
<Label htmlFor="newsletter">Subscribe to newsletter</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox id="notifications" />
|
||||
<Label htmlFor="notifications">Enable email notifications</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const LongLabel: Story = {
|
||||
render: () => (
|
||||
<div className="w-[350px] space-y-2">
|
||||
<Label htmlFor="long-label">
|
||||
This is a very long label that demonstrates how labels wrap when they contain a lot of text and need
|
||||
to span multiple lines
|
||||
</Label>
|
||||
<Input id="long-label" placeholder="Enter value..." />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const HeadlineVariant: Story = {
|
||||
args: {
|
||||
variant: "headline",
|
||||
children: "Headline Label",
|
||||
headlineFontFamily: "system-ui, sans-serif",
|
||||
headlineFontWeight: "600",
|
||||
headlineFontSize: "1.25rem",
|
||||
headlineColor: "#1e293b",
|
||||
headlineOpacity: "1",
|
||||
},
|
||||
argTypes: {
|
||||
headlineFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Headline Styling" },
|
||||
},
|
||||
headlineFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Headline Styling" },
|
||||
},
|
||||
headlineFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Headline Styling" },
|
||||
},
|
||||
headlineColor: {
|
||||
control: "color",
|
||||
table: { category: "Headline Styling" },
|
||||
},
|
||||
headlineOpacity: {
|
||||
control: "text",
|
||||
table: { category: "Headline Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [withHeadlineCSSVariables],
|
||||
};
|
||||
|
||||
export const DescriptionVariant: Story = {
|
||||
args: {
|
||||
variant: "description",
|
||||
children: "Description Label",
|
||||
descriptionFontFamily: "system-ui, sans-serif",
|
||||
descriptionFontWeight: "400",
|
||||
descriptionFontSize: "0.875rem",
|
||||
descriptionColor: "#64748b",
|
||||
descriptionOpacity: "1",
|
||||
},
|
||||
argTypes: {
|
||||
descriptionFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Description Styling" },
|
||||
},
|
||||
descriptionFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Description Styling" },
|
||||
},
|
||||
descriptionFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Description Styling" },
|
||||
},
|
||||
descriptionColor: {
|
||||
control: "color",
|
||||
table: { category: "Description Styling" },
|
||||
},
|
||||
descriptionOpacity: {
|
||||
control: "text",
|
||||
table: { category: "Description Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [withDescriptionCSSVariables],
|
||||
};
|
||||
|
||||
export const DefaultVariant: Story = {
|
||||
args: {
|
||||
variant: "default",
|
||||
children: "Default Label",
|
||||
labelFontFamily: "system-ui, sans-serif",
|
||||
labelFontWeight: "500",
|
||||
labelFontSize: "0.875rem",
|
||||
labelColor: "#1e293b",
|
||||
labelOpacity: "1",
|
||||
},
|
||||
argTypes: {
|
||||
labelFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Default Label Styling" },
|
||||
},
|
||||
labelFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Default Label Styling" },
|
||||
},
|
||||
labelFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Default Label Styling" },
|
||||
},
|
||||
labelColor: {
|
||||
control: "color",
|
||||
table: { category: "Default Label Styling" },
|
||||
},
|
||||
labelOpacity: {
|
||||
control: "text",
|
||||
table: { category: "Default Label Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [withCustomCSSVariables],
|
||||
};
|
||||
123
packages/survey-ui/src/components/general/label.tsx
Normal file
123
packages/survey-ui/src/components/general/label.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import * as React from "react";
|
||||
import { cn, stripInlineStyles } from "@/lib/utils";
|
||||
|
||||
interface LabelProps extends React.ComponentProps<"label"> {
|
||||
/** Label variant for different styling contexts */
|
||||
variant?: "default" | "headline" | "description";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string contains valid HTML markup
|
||||
* @param str - The input string to test
|
||||
* @returns true if the string contains valid HTML elements, false otherwise
|
||||
*/
|
||||
const isValidHTML = (str: string): boolean => {
|
||||
if (!str) return false;
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(str, "text/html");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
if (errorNode) return false;
|
||||
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
function Label({
|
||||
className,
|
||||
variant = "default",
|
||||
children,
|
||||
...props
|
||||
}: Readonly<LabelProps>): React.JSX.Element {
|
||||
const { htmlFor, form, ...restProps } = props;
|
||||
|
||||
// Check if children is a string and contains HTML
|
||||
const childrenString = typeof children === "string" ? children : null;
|
||||
const strippedContent = childrenString ? stripInlineStyles(childrenString) : "";
|
||||
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
|
||||
const safeHtml =
|
||||
isHtml && strippedContent
|
||||
? DOMPurify.sanitize(strippedContent, {
|
||||
ADD_ATTR: ["target"],
|
||||
FORBID_ATTR: ["style"],
|
||||
})
|
||||
: "";
|
||||
|
||||
// Determine variant class
|
||||
let variantClass = "label-default";
|
||||
if (variant === "headline") {
|
||||
variantClass = "label-headline";
|
||||
} else if (variant === "description") {
|
||||
variantClass = "label-description";
|
||||
}
|
||||
|
||||
// Base classes - use flex-col for HTML content to allow line breaks, flex items-center for non-HTML
|
||||
const baseClasses =
|
||||
isHtml && safeHtml
|
||||
? "flex flex-col gap-2 leading-6 select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
||||
: "flex items-center gap-2 leading-6 select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50";
|
||||
|
||||
// If HTML, render with dangerouslySetInnerHTML, otherwise render normally
|
||||
if (isHtml && safeHtml) {
|
||||
if (htmlFor) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
data-variant={variant}
|
||||
className={cn(baseClasses, variantClass, className)}
|
||||
htmlFor={htmlFor}
|
||||
form={form}
|
||||
{...restProps}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="label"
|
||||
data-variant={variant}
|
||||
className={cn(baseClasses, variantClass, className)}
|
||||
{...(restProps as React.HTMLAttributes<HTMLSpanElement>)}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (htmlFor) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"flex items-center gap-2 leading-6 select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
variantClass,
|
||||
className
|
||||
)}
|
||||
htmlFor={htmlFor}
|
||||
form={form}
|
||||
{...restProps}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="label"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"flex items-center gap-2 leading-6 select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
variantClass,
|
||||
className
|
||||
)}
|
||||
{...(restProps as React.HTMLAttributes<HTMLSpanElement>)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
export type { LabelProps };
|
||||
39
packages/survey-ui/src/components/general/popover.tsx
Normal file
39
packages/survey-ui/src/components/general/popover.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: Readonly<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
|
||||
}: Readonly<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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
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 };
|
||||
155
packages/survey-ui/src/components/general/progress.stories.tsx
Normal file
155
packages/survey-ui/src/components/general/progress.stories.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Decorator, Meta, StoryContext, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Progress, type ProgressProps } from "./progress";
|
||||
|
||||
// Styling options for the StylingPlayground story
|
||||
interface StylingOptions {
|
||||
trackHeight: string;
|
||||
trackBgColor: string;
|
||||
trackBorderRadius: string;
|
||||
indicatorBgColor: string;
|
||||
indicatorBorderRadius: string;
|
||||
}
|
||||
|
||||
type StoryProps = ProgressProps & Partial<StylingOptions> & Record<string, unknown>;
|
||||
|
||||
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: React.ComponentType,
|
||||
context: StoryContext<StoryProps>
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
|
||||
const args = context.args as StoryProps;
|
||||
const { 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],
|
||||
};
|
||||
29
packages/survey-ui/src/components/general/progress.tsx
Normal file
29
packages/survey-ui/src/components/general/progress.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ProgressProps extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
value?: number;
|
||||
}
|
||||
|
||||
function Progress({ className, value, ...props }: Readonly<ProgressProps>): React.JSX.Element {
|
||||
const progressValue: number = typeof value === "number" ? value : 0;
|
||||
return (
|
||||
// @ts-expect-error - React types version mismatch - the project uses React 19 types, but some Radix UI packages (@radix-ui/react-progress) bundle their own older React types, creating incompatible Ref type definitions
|
||||
<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 };
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Label } from "./label";
|
||||
import { RadioGroup, RadioGroupItem } from "./radio-group";
|
||||
|
||||
const meta: Meta<typeof RadioGroup> = {
|
||||
title: "UI-package/General/RadioGroup",
|
||||
component: RadioGroup,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A radio group component built with Radix UI primitives. Allows users to select one option from a set of mutually exclusive choices.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
defaultValue: {
|
||||
control: { type: "text" },
|
||||
description: "The default selected value",
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "Whether the entire radio group is disabled",
|
||||
},
|
||||
required: {
|
||||
control: { type: "boolean" },
|
||||
description: "Whether a selection is required",
|
||||
},
|
||||
dir: {
|
||||
control: { type: "select" },
|
||||
options: ["ltr", "rtl"],
|
||||
description: "Text direction",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args: React.ComponentProps<typeof RadioGroup>) => (
|
||||
<RadioGroup defaultValue="option1" {...args}>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option1" id="option1" />
|
||||
<Label htmlFor="option1">Option 1</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option2" id="option2" />
|
||||
<Label htmlFor="option2">Option 2</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option3" id="option3" />
|
||||
<Label htmlFor="option3">Option 3</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
),
|
||||
args: {
|
||||
defaultValue: "option1",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutDefault: Story = {
|
||||
render: () => (
|
||||
<RadioGroup>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option1" id="no-default-1" />
|
||||
<Label htmlFor="no-default-1">Option 1</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option2" id="no-default-2" />
|
||||
<Label htmlFor="no-default-2">Option 2</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option3" id="no-default-3" />
|
||||
<Label htmlFor="no-default-3">Option 3</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => (
|
||||
<RadioGroup defaultValue="option1" disabled>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option1" id="disabled-1" />
|
||||
<Label htmlFor="disabled-1">Option 1 (Selected)</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option2" id="disabled-2" />
|
||||
<Label htmlFor="disabled-2">Option 2</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option3" id="disabled-3" />
|
||||
<Label htmlFor="disabled-3">Option 3</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const SingleDisabledOption: Story = {
|
||||
render: () => (
|
||||
<RadioGroup defaultValue="option1">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option1" id="single-disabled-1" />
|
||||
<Label htmlFor="single-disabled-1">Option 1</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option2" id="single-disabled-2" disabled />
|
||||
<Label htmlFor="single-disabled-2">Option 2 (Disabled)</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option3" id="single-disabled-3" />
|
||||
<Label htmlFor="single-disabled-3">Option 3</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const PaymentMethod: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Payment Method</h3>
|
||||
<RadioGroup defaultValue="credit-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="credit-card" id="credit-card" />
|
||||
<Label htmlFor="credit-card">Credit Card</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="paypal" id="paypal" />
|
||||
<Label htmlFor="paypal">PayPal</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="bank-transfer" id="bank-transfer" />
|
||||
<Label htmlFor="bank-transfer">Bank Transfer</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="crypto" id="crypto" disabled />
|
||||
<Label htmlFor="crypto">Cryptocurrency (Coming Soon)</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const SurveyElement: Story = {
|
||||
render: () => (
|
||||
<div className="w-[400px] space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">How satisfied are you with our service?</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Please select one option that best describes your experience.
|
||||
</p>
|
||||
</div>
|
||||
<RadioGroup>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="very-satisfied" id="very-satisfied" />
|
||||
<Label htmlFor="very-satisfied">Very satisfied</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="satisfied" id="satisfied" />
|
||||
<Label htmlFor="satisfied">Satisfied</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="neutral" id="neutral" />
|
||||
<Label htmlFor="neutral">Neutral</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="dissatisfied" id="dissatisfied" />
|
||||
<Label htmlFor="dissatisfied">Dissatisfied</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="very-dissatisfied" id="very-dissatisfied" />
|
||||
<Label htmlFor="very-dissatisfied">Very dissatisfied</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithDescriptions: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Choose your plan</h3>
|
||||
<RadioGroup defaultValue="basic">
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="basic" id="plan-basic" />
|
||||
<Label htmlFor="plan-basic" className="font-medium">
|
||||
Basic Plan
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-muted-foreground ml-6 text-sm">
|
||||
Perfect for individuals. Includes basic features and 5GB storage.
|
||||
</p>
|
||||
<p className="ml-6 text-sm font-medium">$9/month</p>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="pro" id="plan-pro" />
|
||||
<Label htmlFor="plan-pro" className="font-medium">
|
||||
Pro Plan
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-muted-foreground ml-6 text-sm">
|
||||
Great for small teams. Advanced features and 50GB storage.
|
||||
</p>
|
||||
<p className="ml-6 text-sm font-medium">$29/month</p>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="enterprise" id="plan-enterprise" />
|
||||
<Label htmlFor="plan-enterprise" className="font-medium">
|
||||
Enterprise Plan
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-muted-foreground ml-6 text-sm">
|
||||
For large organizations. Custom features and unlimited storage.
|
||||
</p>
|
||||
<p className="ml-6 text-sm font-medium">Contact sales</p>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">
|
||||
Gender <span className="text-red-500">*</span>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">This field is required</p>
|
||||
</div>
|
||||
<RadioGroup required>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="male" id="gender-male" />
|
||||
<Label htmlFor="gender-male">Male</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="female" id="gender-female" />
|
||||
<Label htmlFor="gender-female">Female</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="other" id="gender-other" />
|
||||
<Label htmlFor="gender-other">Other</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="prefer-not-to-say" id="gender-prefer-not" />
|
||||
<Label htmlFor="gender-prefer-not">Prefer not to say</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithRTL: Story = {
|
||||
render: () => (
|
||||
<div className="space-y-4">
|
||||
<RadioGroup dir="rtl" required>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="male" id="gender-male" />
|
||||
<Label htmlFor="gender-male">Male</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="female" id="gender-female" />
|
||||
<Label htmlFor="gender-female">Female</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithErrorMessage: Story = {
|
||||
render: () => (
|
||||
<RadioGroup errorMessage="Please select an option">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option1" id="option1" />
|
||||
<Label htmlFor="option1">Option 1</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option2" id="option2" />
|
||||
<Label htmlFor="option2">Option 2</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option3" id="option3" />
|
||||
<Label htmlFor="option3">Option 3</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithErrorMessageAndRTL: Story = {
|
||||
render: () => (
|
||||
<RadioGroup errorMessage="يرجى اختيار الخيار" dir="rtl">
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option1" id="option1" />
|
||||
<Label htmlFor="option1">اختر الخيار 1</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option2" id="option2" />
|
||||
<Label htmlFor="option2">اختر الخيار 2</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="option3" id="option3" />
|
||||
<Label htmlFor="option3">اختر الخيار 3</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
),
|
||||
};
|
||||
58
packages/survey-ui/src/components/general/radio-group.tsx
Normal file
58
packages/survey-ui/src/components/general/radio-group.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { AlertCircle, CircleIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
errorMessage,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root> & {
|
||||
errorMessage?: string;
|
||||
dir?: "ltr" | "rtl";
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex w-full gap-2" dir={dir}>
|
||||
{errorMessage ? <div className="bg-destructive min-h-full w-1" /> : null}
|
||||
<div className="w-full space-y-2">
|
||||
{errorMessage ? (
|
||||
<div className="text-destructive flex items-center gap-1 text-sm">
|
||||
<AlertCircle className="size-4" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<RadioGroupPrimitive.Root
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
data-slot="radio-group"
|
||||
dir={dir}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>): React.JSX.Element {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"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 aspect-square size-4 shrink-0 rounded-full border bg-white shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex items-center justify-center">
|
||||
<CircleIcon className="fill-brand stroke-brand size-2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
466
packages/survey-ui/src/components/general/smileys.tsx
Normal file
466
packages/survey-ui/src/components/general/smileys.tsx
Normal 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" />];
|
||||
153
packages/survey-ui/src/components/general/textarea.stories.tsx
Normal file
153
packages/survey-ui/src/components/general/textarea.stories.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Label } from "./label";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
const meta: Meta<typeof Textarea> = {
|
||||
title: "UI-package/General/Textarea",
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A flexible textarea component with error handling, custom styling, and RTL support. Built with accessibility in mind using proper ARIA attributes and automatic resizing.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: { type: "text" },
|
||||
description: "Placeholder text for the textarea",
|
||||
},
|
||||
disabled: {
|
||||
control: { type: "boolean" },
|
||||
description: "Whether the textarea is disabled",
|
||||
},
|
||||
required: {
|
||||
control: { type: "boolean" },
|
||||
description: "Whether the textarea is required",
|
||||
},
|
||||
rows: {
|
||||
control: { type: "number" },
|
||||
description: "Number of visible text lines",
|
||||
},
|
||||
errorMessage: {
|
||||
control: "text",
|
||||
description: "Error message to display below the textarea",
|
||||
},
|
||||
dir: {
|
||||
control: { type: "select" },
|
||||
options: ["ltr", "rtl"],
|
||||
description: "Text direction",
|
||||
},
|
||||
style: {
|
||||
control: { type: "object" },
|
||||
description: "Custom styling for the textarea",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
placeholder: "Enter your text...",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
value:
|
||||
"This textarea has some predefined content that spans multiple lines.\n\nIt demonstrates how the component handles existing text.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
value: "This textarea is disabled and cannot be edited.",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRows: Story = {
|
||||
args: {
|
||||
rows: 5,
|
||||
placeholder: "This textarea has 5 visible rows...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => (
|
||||
<div className="w-[300px] space-y-2">
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea id="message" placeholder="Enter your message..." />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<div className="w-[500px] space-y-2">
|
||||
<Label htmlFor="long-content">Terms and Conditions</Label>
|
||||
<Textarea
|
||||
id="long-content"
|
||||
rows={8}
|
||||
readOnly
|
||||
value={`Terms of Service
|
||||
|
||||
1. Acceptance of Terms
|
||||
By accessing and using this service, you accept and agree to be bound by the terms and provision of this agreement.
|
||||
|
||||
2. Use License
|
||||
Permission is granted to temporarily download one copy of the materials on this website for personal, non-commercial transitory viewing only.
|
||||
|
||||
3. Disclaimer
|
||||
The materials on this website are provided on an 'as is' basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.
|
||||
|
||||
4. Limitations
|
||||
In no event shall our company or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on this website.`}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
placeholder: "Enter your message",
|
||||
defaultValue: "Too short",
|
||||
errorMessage: "Message must be at least 10 characters long",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
dir: "rtl",
|
||||
placeholder: "أدخل رسالتك هنا",
|
||||
defaultValue: "نص تجريبي طويل",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
placeholder: "Custom styled textarea",
|
||||
style: {
|
||||
height: "120px",
|
||||
borderRadius: "12px",
|
||||
padding: "16px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
border: "2px solid #e9ecef",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithErrorAndRTL: Story = {
|
||||
args: {
|
||||
dir: "rtl",
|
||||
placeholder: "أدخل رسالتك",
|
||||
errorMessage: "هذا الحقل مطلوب",
|
||||
},
|
||||
};
|
||||
30
packages/survey-ui/src/components/general/textarea.tsx
Normal file
30
packages/survey-ui/src/components/general/textarea.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
dir={dir}
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn(
|
||||
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none placeholder:text-sm focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
49
packages/survey-ui/src/index.ts
Normal file
49
packages/survey-ui/src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import "@/styles/globals.css";
|
||||
|
||||
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";
|
||||
113
packages/survey-ui/src/lib/locale.test.ts
Normal file
113
packages/survey-ui/src/lib/locale.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ar, de, enUS, es, fr, hi, it, ja, nl, pt, ptBR, ro, ru, uz, zhCN, zhTW } from "date-fns/locale";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getDateFnsLocale } from "./locale";
|
||||
|
||||
describe("getDateFnsLocale", () => {
|
||||
test("returns enUS when localeCode is undefined", () => {
|
||||
expect(getDateFnsLocale()).toBe(enUS);
|
||||
});
|
||||
|
||||
test("returns enUS when localeCode is empty string", () => {
|
||||
expect(getDateFnsLocale("")).toBe(enUS);
|
||||
});
|
||||
|
||||
test("handles English locales", () => {
|
||||
expect(getDateFnsLocale("en")).toBe(enUS);
|
||||
expect(getDateFnsLocale("en-US")).toBe(enUS);
|
||||
expect(getDateFnsLocale("EN-US")).toBe(enUS);
|
||||
});
|
||||
|
||||
test("handles German locales", () => {
|
||||
expect(getDateFnsLocale("de")).toBe(de);
|
||||
expect(getDateFnsLocale("de-DE")).toBe(de);
|
||||
expect(getDateFnsLocale("DE")).toBe(de);
|
||||
});
|
||||
|
||||
test("handles Spanish locales", () => {
|
||||
expect(getDateFnsLocale("es")).toBe(es);
|
||||
expect(getDateFnsLocale("es-ES")).toBe(es);
|
||||
});
|
||||
|
||||
test("handles French locales", () => {
|
||||
expect(getDateFnsLocale("fr")).toBe(fr);
|
||||
expect(getDateFnsLocale("fr-FR")).toBe(fr);
|
||||
});
|
||||
|
||||
test("handles Japanese locales", () => {
|
||||
expect(getDateFnsLocale("ja")).toBe(ja);
|
||||
expect(getDateFnsLocale("ja-JP")).toBe(ja);
|
||||
});
|
||||
|
||||
test("handles Dutch locales", () => {
|
||||
expect(getDateFnsLocale("nl")).toBe(nl);
|
||||
expect(getDateFnsLocale("nl-NL")).toBe(nl);
|
||||
});
|
||||
|
||||
test("handles Portuguese locales - Brazilian", () => {
|
||||
expect(getDateFnsLocale("pt")).toBe(ptBR);
|
||||
expect(getDateFnsLocale("pt-BR")).toBe(ptBR);
|
||||
expect(getDateFnsLocale("pt-br")).toBe(ptBR);
|
||||
});
|
||||
|
||||
test("handles Portuguese locales - Portugal", () => {
|
||||
expect(getDateFnsLocale("pt-PT")).toBe(pt);
|
||||
expect(getDateFnsLocale("pt-pt")).toBe(pt);
|
||||
});
|
||||
|
||||
test("handles Romanian locales", () => {
|
||||
expect(getDateFnsLocale("ro")).toBe(ro);
|
||||
expect(getDateFnsLocale("ro-RO")).toBe(ro);
|
||||
});
|
||||
|
||||
test("handles Arabic locales", () => {
|
||||
expect(getDateFnsLocale("ar")).toBe(ar);
|
||||
expect(getDateFnsLocale("ar-SA")).toBe(ar);
|
||||
});
|
||||
|
||||
test("handles Italian locales", () => {
|
||||
expect(getDateFnsLocale("it")).toBe(it);
|
||||
expect(getDateFnsLocale("it-IT")).toBe(it);
|
||||
});
|
||||
|
||||
test("handles Russian locales", () => {
|
||||
expect(getDateFnsLocale("ru")).toBe(ru);
|
||||
expect(getDateFnsLocale("ru-RU")).toBe(ru);
|
||||
});
|
||||
|
||||
test("handles Uzbek locales", () => {
|
||||
expect(getDateFnsLocale("uz")).toBe(uz);
|
||||
expect(getDateFnsLocale("uz-UZ")).toBe(uz);
|
||||
});
|
||||
|
||||
test("handles Hindi locales", () => {
|
||||
expect(getDateFnsLocale("hi")).toBe(hi);
|
||||
expect(getDateFnsLocale("hi-IN")).toBe(hi);
|
||||
});
|
||||
|
||||
test("handles Chinese Simplified locales", () => {
|
||||
expect(getDateFnsLocale("zh")).toBe(zhCN);
|
||||
expect(getDateFnsLocale("zh-Hans")).toBe(zhCN);
|
||||
expect(getDateFnsLocale("zh-hans")).toBe(zhCN);
|
||||
expect(getDateFnsLocale("zh-CN")).toBe(zhCN);
|
||||
expect(getDateFnsLocale("zh-cn")).toBe(zhCN);
|
||||
});
|
||||
|
||||
test("handles Chinese Traditional locales", () => {
|
||||
expect(getDateFnsLocale("zh-Hant")).toBe(zhTW);
|
||||
expect(getDateFnsLocale("zh-hant")).toBe(zhTW);
|
||||
expect(getDateFnsLocale("zh-TW")).toBe(zhTW);
|
||||
expect(getDateFnsLocale("zh-tw")).toBe(zhTW);
|
||||
expect(getDateFnsLocale("zh-HK")).toBe(zhTW);
|
||||
expect(getDateFnsLocale("zh-hk")).toBe(zhTW);
|
||||
});
|
||||
|
||||
test("returns enUS for unknown locale codes", () => {
|
||||
expect(getDateFnsLocale("unknown")).toBe(enUS);
|
||||
expect(getDateFnsLocale("xx-XX")).toBe(enUS);
|
||||
});
|
||||
|
||||
test("handles case-insensitive locale codes", () => {
|
||||
expect(getDateFnsLocale("FR")).toBe(fr);
|
||||
expect(getDateFnsLocale("Fr-Fr")).toBe(fr);
|
||||
});
|
||||
});
|
||||
52
packages/survey-ui/src/lib/locale.ts
Normal file
52
packages/survey-ui/src/lib/locale.ts
Normal 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;
|
||||
}
|
||||
442
packages/survey-ui/src/lib/story-helpers.tsx
Normal file
442
packages/survey-ui/src/lib/story-helpers.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
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>>(
|
||||
additionalMappings?: CSSVarMapping
|
||||
): Decorator<T & Record<string, unknown>> {
|
||||
const fullMapping = { ...CSS_VAR_MAP, ...additionalMappings };
|
||||
|
||||
function CSSVariablesDecorator(
|
||||
Story: React.ComponentType,
|
||||
context: StoryContext<T & Record<string, unknown>>
|
||||
): 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 & Record<string, unknown>;
|
||||
}
|
||||
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-[600px]">
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CSSVariablesDecorator.displayName = "CSSVariablesDecorator";
|
||||
return CSSVariablesDecorator;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stateful Render Function Creator
|
||||
// ============================================================================
|
||||
|
||||
export function createStatefulRender<
|
||||
TValue,
|
||||
TProps extends { value?: TValue; onChange?: (v: TValue) => void } & Record<string, unknown>,
|
||||
>(Component: any): (args: TProps) => React.ReactElement {
|
||||
function StatefulRender(args: Readonly<TProps>): 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;
|
||||
}
|
||||
45
packages/survey-ui/src/lib/utils.test.ts
Normal file
45
packages/survey-ui/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { cn } from "./utils";
|
||||
|
||||
describe("cn", () => {
|
||||
test("merges class names correctly", () => {
|
||||
expect(cn("foo", "bar")).toBe("foo bar");
|
||||
});
|
||||
|
||||
test("handles conditional classes", () => {
|
||||
const condition = false;
|
||||
expect(cn("foo", condition && "bar", "baz")).toBe("foo baz");
|
||||
});
|
||||
|
||||
test("handles undefined and null", () => {
|
||||
expect(cn("foo", undefined, null, "bar")).toBe("foo bar");
|
||||
});
|
||||
|
||||
test("merges Tailwind classes and resolves conflicts", () => {
|
||||
// tailwind-merge should resolve conflicting classes
|
||||
const result = cn("px-2", "px-4");
|
||||
expect(result).toBe("px-4");
|
||||
});
|
||||
|
||||
test("handles empty input", () => {
|
||||
expect(cn()).toBe("");
|
||||
});
|
||||
|
||||
test("handles arrays of classes", () => {
|
||||
expect(cn(["foo", "bar"], "baz")).toBe("foo bar baz");
|
||||
});
|
||||
|
||||
test("handles objects with conditional classes", () => {
|
||||
expect(cn({ foo: true, bar: false, baz: true })).toBe("foo baz");
|
||||
});
|
||||
|
||||
test("handles mixed inputs", () => {
|
||||
expect(cn("foo", ["bar", "baz"], { qux: true })).toBe("foo bar baz qux");
|
||||
});
|
||||
|
||||
test("handles custom Tailwind tokens", () => {
|
||||
// Test that custom tokens work with the extended tailwind-merge
|
||||
const result = cn("text-input", "text-button");
|
||||
expect(result).toContain("text-button");
|
||||
});
|
||||
});
|
||||
37
packages/survey-ui/src/lib/utils.ts
Normal file
37
packages/survey-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { extendTailwindMerge } from "tailwind-merge";
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
// Custom tokens from `packages/survey-ui/tailwind.config.ts`
|
||||
fontSize: ["input", "option", "button"],
|
||||
textColor: ["input-text", "input-placeholder", "option-label", "button-text"],
|
||||
},
|
||||
} as Parameters<typeof extendTailwindMerge>[0]);
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes
|
||||
* Combines clsx and tailwind-merge for optimal class merging
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip inline style attributes from HTML string to avoid CSP violations
|
||||
* Uses DOMPurify for secure, proper HTML parsing instead of regex
|
||||
* @param html - The HTML string to process
|
||||
* @returns HTML string with all style attributes removed
|
||||
*/
|
||||
export const stripInlineStyles = (html: string): string => {
|
||||
if (!html) return html;
|
||||
|
||||
// Use DOMPurify to safely remove style attributes
|
||||
// This is more secure than regex-based approaches and handles edge cases properly
|
||||
return DOMPurify.sanitize(html, {
|
||||
FORBID_ATTR: ["style"],
|
||||
// Keep other attributes and tags as-is, only remove style attributes
|
||||
KEEP_CONTENT: true,
|
||||
});
|
||||
};
|
||||
154
packages/survey-ui/src/lib/video.test.ts
Normal file
154
packages/survey-ui/src/lib/video.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "./video";
|
||||
|
||||
describe("checkForYoutubeUrl", () => {
|
||||
test("returns true for valid YouTube URLs with https", () => {
|
||||
expect(checkForYoutubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true);
|
||||
expect(checkForYoutubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true);
|
||||
expect(checkForYoutubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true);
|
||||
expect(checkForYoutubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true);
|
||||
expect(checkForYoutubeUrl("https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe(true);
|
||||
expect(checkForYoutubeUrl("https://youtube-nocookie.com/embed/dQw4w9WgXcQ")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for YouTube URLs with http", () => {
|
||||
expect(checkForYoutubeUrl("http://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for invalid URLs", () => {
|
||||
expect(checkForYoutubeUrl("not-a-url")).toBe(false);
|
||||
expect(checkForYoutubeUrl("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-YouTube domains", () => {
|
||||
expect(checkForYoutubeUrl("https://vimeo.com/123456")).toBe(false);
|
||||
expect(checkForYoutubeUrl("https://example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForVimeoUrl", () => {
|
||||
test("returns true for valid Vimeo URLs with https", () => {
|
||||
expect(checkForVimeoUrl("https://www.vimeo.com/123456789")).toBe(true);
|
||||
expect(checkForVimeoUrl("https://vimeo.com/123456789")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for Vimeo URLs with http", () => {
|
||||
expect(checkForVimeoUrl("http://www.vimeo.com/123456789")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for invalid URLs", () => {
|
||||
expect(checkForVimeoUrl("not-a-url")).toBe(false);
|
||||
expect(checkForVimeoUrl("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-Vimeo domains", () => {
|
||||
expect(checkForVimeoUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(false);
|
||||
expect(checkForVimeoUrl("https://example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkForLoomUrl", () => {
|
||||
test("returns true for valid Loom URLs with https", () => {
|
||||
expect(checkForLoomUrl("https://www.loom.com/share/abc123")).toBe(true);
|
||||
expect(checkForLoomUrl("https://loom.com/share/abc123")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for Loom URLs with http", () => {
|
||||
expect(checkForLoomUrl("http://www.loom.com/share/abc123")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for invalid URLs", () => {
|
||||
expect(checkForLoomUrl("not-a-url")).toBe(false);
|
||||
expect(checkForLoomUrl("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-Loom domains", () => {
|
||||
expect(checkForLoomUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(false);
|
||||
expect(checkForLoomUrl("https://example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToEmbedUrl", () => {
|
||||
describe("YouTube URL conversion", () => {
|
||||
test("converts youtu.be URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://youtu.be/dQw4w9WgXcQ");
|
||||
expect(result).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
test("converts www.youtu.be URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://www.youtu.be/dQw4w9WgXcQ");
|
||||
expect(result).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
test("converts youtube.com/watch URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
|
||||
expect(result).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
test("converts youtube.com/embed URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
||||
expect(result).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
test("converts youtube-nocookie.com/embed URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ");
|
||||
expect(result).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
test("handles YouTube URLs with additional parameters", () => {
|
||||
const result = convertToEmbedUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10s");
|
||||
expect(result).toBe("https://www.youtube.com/embed/dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
test("returns undefined for invalid YouTube URLs", () => {
|
||||
const result = convertToEmbedUrl("https://www.youtube.com/invalid");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vimeo URL conversion", () => {
|
||||
test("converts vimeo.com URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://www.vimeo.com/123456789");
|
||||
expect(result).toBe("https://player.vimeo.com/video/123456789");
|
||||
});
|
||||
|
||||
test("converts www.vimeo.com URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://vimeo.com/987654321");
|
||||
expect(result).toBe("https://player.vimeo.com/video/987654321");
|
||||
});
|
||||
|
||||
test("returns undefined for invalid Vimeo URLs", () => {
|
||||
const result = convertToEmbedUrl("https://www.vimeo.com/invalid");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loom URL conversion", () => {
|
||||
test("converts loom.com/share URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://www.loom.com/share/abc123def456");
|
||||
expect(result).toBe("https://www.loom.com/embed/abc123def456");
|
||||
});
|
||||
|
||||
test("converts www.loom.com/share URLs to embed format", () => {
|
||||
const result = convertToEmbedUrl("https://loom.com/share/xyz789");
|
||||
expect(result).toBe("https://www.loom.com/embed/xyz789");
|
||||
});
|
||||
|
||||
test("returns undefined for invalid Loom URLs", () => {
|
||||
const result = convertToEmbedUrl("https://www.loom.com/invalid");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unsupported URLs", () => {
|
||||
test("returns undefined for unsupported video platforms", () => {
|
||||
expect(convertToEmbedUrl("https://example.com/video")).toBeUndefined();
|
||||
expect(convertToEmbedUrl("https://dailymotion.com/video/xyz")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for invalid URLs", () => {
|
||||
expect(convertToEmbedUrl("not-a-url")).toBeUndefined();
|
||||
expect(convertToEmbedUrl("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
121
packages/survey-ui/src/lib/video.ts
Normal file
121
packages/survey-ui/src/lib/video.ts
Normal 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 {
|
||||
// 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 {
|
||||
// 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 {
|
||||
// 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 = regExp.exec(url);
|
||||
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;
|
||||
};
|
||||
232
packages/survey-ui/src/styles/globals.css
Normal file
232
packages/survey-ui/src/styles/globals.css
Normal file
@@ -0,0 +1,232 @@
|
||||
/* =============================================================================
|
||||
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;
|
||||
|
||||
/* NOSONAR: Tailwind CSS v4 directives - @config and @source are valid at-rules */
|
||||
@config "../../tailwind.config.ts";
|
||||
/* NOSONAR */
|
||||
@source "../../src/**/*.{ts,tsx,js,jsx}";
|
||||
/* NOSONAR */
|
||||
|
||||
/* =============================================================================
|
||||
Design Tokens
|
||||
============================================================================= */
|
||||
|
||||
:root {
|
||||
/* ---------------------------------------------------------------------------
|
||||
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;
|
||||
--slate-50: rgb(248, 250, 252);
|
||||
--slate-100: rgb(241 245 249);
|
||||
--slate-200: rgb(226 232 240);
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Survey general Colors
|
||||
The primary accent color used throughout the survey. Override this to
|
||||
customize the brand appearance.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-survey-brand-color: #64748b;
|
||||
|
||||
--fb-accent-background-color: var(--slate-200);
|
||||
--fb-accent-background-color-selected: var(--slate-100);
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Element Headline Tokens
|
||||
Used for question headlines and main element titles.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-element-headline-font-family: inherit;
|
||||
--fb-element-headline-font-weight: 400;
|
||||
--fb-element-headline-font-size: 1rem;
|
||||
--fb-element-headline-color: (--input);
|
||||
--fb-element-headline-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: var(--input);
|
||||
--fb-element-description-opacity: 1;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Label Tokens
|
||||
Used for form labels and secondary text.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-label-font-family: inherit;
|
||||
--fb-label-font-weight: 400;
|
||||
--fb-label-font-size: 0.875rem;
|
||||
--fb-label-color: var(--foreground);
|
||||
--fb-label-opacity: 1;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
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: var(--slate-50);
|
||||
--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 !important;
|
||||
--fb-input-font-weight: 400;
|
||||
--fb-input-color: var(--foreground);
|
||||
--fb-input-placeholder-color: var(--fb-input-color);
|
||||
--fb-input-placeholder-opacity: 0.5;
|
||||
--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-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) !important;
|
||||
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) !important;
|
||||
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) !important;
|
||||
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);
|
||||
}
|
||||
118
packages/survey-ui/tailwind.config.ts
Normal file
118
packages/survey-ui/tailwind.config.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
function getForeground(color: string) {
|
||||
// simple luminance check
|
||||
const rgb = color.match(/\w\w/g)?.map((x) => Number.parseInt(x, 16)) ?? [0, 0, 0];
|
||||
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
||||
return luminance > 0.5 ? "#000000" : "#ffffff";
|
||||
}
|
||||
|
||||
export default {
|
||||
darkMode: "class",
|
||||
// Scope all utilities to #fbjs when used in surveys package
|
||||
// This ensures proper specificity and prevents conflicts with preflight CSS
|
||||
important: "#fbjs",
|
||||
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(--fb-accent-background-color)",
|
||||
selected: "var(--fb-accent-background-color-selected)",
|
||||
},
|
||||
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)",
|
||||
foreground: getForeground("var(--fb-survey-brand-color)"),
|
||||
},
|
||||
// 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": "color-mix(in srgb, var(--fb-option-bg-color) 95%, black 5%)",
|
||||
"option-label": "var(--fb-option-label-color)",
|
||||
"option-selected-bg": "color-mix(in srgb, var(--fb-option-bg-color) 95%, black)",
|
||||
"option-hover-bg": "color-mix(in srgb, var(--fb-option-bg-color) 95%, black)",
|
||||
"input-selected-bg": "color-mix(in srgb, var(--fb-input-bg-color) 95%, 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;
|
||||
15
packages/survey-ui/tsconfig.json
Normal file
15
packages/survey-ui/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"extends": "@formbricks/config-typescript/react-library.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
67
packages/survey-ui/vite.config.mts
Normal file
67
packages/survey-ui/vite.config.mts
Normal file
@@ -0,0 +1,67 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
/**
|
||||
* Plugin to strip "use client" directives from bundled dependencies.
|
||||
*
|
||||
* This fixes warnings from Radix UI and other packages that include "use client"
|
||||
* directives for Next.js React Server Components compatibility. When Vite bundles
|
||||
* these packages, it emits warnings because module-level directives aren't supported
|
||||
* in the bundled output.
|
||||
*
|
||||
* The "use client" directive is only relevant for Next.js RSC and doesn't affect
|
||||
* our library build, so it's safe to remove during bundling.
|
||||
*
|
||||
* Related issue: https://github.com/TanStack/query/issues/5175
|
||||
*/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
/^@formkit\/auto-animate/,
|
||||
/^@radix-ui/,
|
||||
"class-variance-authority",
|
||||
"clsx",
|
||||
/^date-fns/,
|
||||
"isomorphic-dompurify",
|
||||
"lucide-react",
|
||||
/^react-day-picker/,
|
||||
"tailwind-merge",
|
||||
],
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
dts({ include: ["src"] }),
|
||||
tailwindcss(),
|
||||
],
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html", "lcov"],
|
||||
reportsDirectory: "./coverage",
|
||||
include: ["src/lib/**/*.ts"],
|
||||
exclude: ["**/*.test.ts", "**/*.stories.tsx"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,44 +5,32 @@ checksums:
|
||||
common/apply: eb61ee65aa06a70b2bc908cf0e9f2104
|
||||
common/auto_close_wrapper: fd0948e4d47150513e2b354c7685c1a7
|
||||
common/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||
common/click_or_drag_to_upload_files: 64f59bc339568d52b8464b82546b70ea
|
||||
common/close_survey: 36e6aaa19051cb253aa155ad69a9edbc
|
||||
common/company_logo: 82d5c0d5994508210ee02d684819f4b8
|
||||
common/delete_file: ee2f3e3fb4d2b227aea90e44fdaca861
|
||||
common/file_upload: fe254dc8892e76cf5a008d712c6ce9c8
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/language_switch: fd72a9ada13f672f4fd5da863b22cc46
|
||||
common/less_than_x_minutes: 8a8528651d0b60dc93be451abf6a139b
|
||||
common/move_down: 479ab9ea756d1d814f7dcfe7cd7c21ba
|
||||
common/move_up: c18fce90954378eb8573f5c3050bf140
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/open_in_new_tab: 6844e4922a7a40a7ee25c10ea109cdeb
|
||||
common/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
common/options: 59156082418d80acb211f973b1218f11
|
||||
common/people_responded: b685fb877090d8658db724ad07a0dbd8
|
||||
common/please_retry_now_or_try_again_later: 949a3841e2eb01fa249790a42bf23aa5
|
||||
common/please_specify: e1faa6cd085144f7339c7e74dc6fb366
|
||||
common/powered_by: 6b6f88e2fa5a1ecec6cebf813abaeebb
|
||||
common/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
|
||||
common/protected_by_reCAPTCHA_and_the_Google: 32de026bff5d52e9edf5410d7d7b835f
|
||||
common/question: 0576462ce60d4263d7c482463fcc9547
|
||||
common/question_video: cc554b661fd62ac59db500307b3ba44e
|
||||
common/ranking_items: 463f2eb500f1b42fbce6cec17612fb9a
|
||||
common/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
common/respondents_will_not_see_this_card: 18c3dd44d6ff6ca2310ad196b84f30d3
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/retrying: 0cb623dbdcbf16d3680f0180ceac734c
|
||||
common/select_a_date: 521e4a705800da06d091fde3e801ce02
|
||||
common/select_for_ranking: e5f4e20752d1c2d852cd02dc3a0e9dd0
|
||||
common/sending_responses: 184772f70cca69424eaf34f73520789f
|
||||
common/takes: 01f96e2e84741ea8392d97ff4bd2aa52
|
||||
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
|
||||
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
|
||||
common/upload_files_by_clicking_or_dragging_them_here: 771d058cb341fe8f5c5866d0a617d9d2
|
||||
common/uploading: 31c3e8fa5eca1a3108d3fa6178962d5d
|
||||
common/x_minutes: bf6ec8800c29b1447226447a991b9510
|
||||
common/x_plus_minutes: 2ef597aa029e3c71d442455fbb751991
|
||||
common/you_have_selected_x_date: 38eae371bac832a099c235cac97f3ef3
|
||||
common/you_have_successfully_uploaded_the_file: 42fa83be555dc13e619241502cda6d9c
|
||||
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
|
||||
errors/file_input/duplicate_files: 198dd29e67beb6abc5b2534ede7d7f68
|
||||
errors/file_input/file_size_exceeded: 072045b042a39fa1df76200f8fa36dd4
|
||||
@@ -60,6 +48,7 @@ checksums:
|
||||
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
|
||||
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
|
||||
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
|
||||
errors/please_select_an_option: 9fede3bb9ded29301e89b98616e3583a
|
||||
errors/please_upload_a_file: 4356dfca88553acb377664c923c2d6b7
|
||||
errors/recaptcha_error/message: b3f2c5950cbc0887f391f9e2bccb676e
|
||||
errors/recaptcha_error/title: 8e923ec38a92041569879a39c6467131
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "تطبيق",
|
||||
"auto_close_wrapper": "غلاف إغلاق تلقائي",
|
||||
"back": "رجوع",
|
||||
"click_or_drag_to_upload_files": "انقر أو اسحب لتحميل الملفات.",
|
||||
"close_survey": "إغلاق الاستبيان",
|
||||
"company_logo": "شعار الشركة",
|
||||
"delete_file": "حذف الملف",
|
||||
"file_upload": "تحميل الملف",
|
||||
"finish": "إنهاء",
|
||||
"language_switch": "تبديل اللغة",
|
||||
"less_than_x_minutes": "{count, plural, one {أقل من دقيقة واحدة} two {أقل من دقيقتين} few {أقل من {count} دقائق} many {أقل من {count} دقيقة} other {أقل من {count} دقيقة}}",
|
||||
"move_down": "نقل {item} للأسفل",
|
||||
"move_up": "نقل {item} للأعلى",
|
||||
"next": "التالي",
|
||||
"open_in_new_tab": "فتح في علامة تبويب جديدة",
|
||||
"optional": "اختياري",
|
||||
"options": "خيارات",
|
||||
"people_responded": "{count, plural, one {شخص واحد استجاب} two {شخصان استجابا} few {{count} أشخاص استجابوا} many {{count} شخصًا استجابوا} other {{count} شخص استجابوا}}",
|
||||
"please_retry_now_or_try_again_later": "يرجى إعادة المحاولة الآن أو المحاولة مرة أخرى لاحقًا.",
|
||||
"please_specify": "يرجى التحديد",
|
||||
"powered_by": "مشغل بواسطة",
|
||||
"privacy_policy": "سياسة الخصوصية",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "محمي بواسطة reCAPTCHA و Google",
|
||||
"question": "سؤال",
|
||||
"question_video": "فيديو السؤال",
|
||||
"ranking_items": "عناصر الترتيب",
|
||||
"required": "مطلوب",
|
||||
"respondents_will_not_see_this_card": "لن يرى المستجيبون هذه البطاقة",
|
||||
"retry": "إعادة المحاولة",
|
||||
"retrying": "إعادة المحاولة...",
|
||||
"select_a_date": "اختر تاريخًا",
|
||||
"select_for_ranking": "اختر {item} للترتيب",
|
||||
"sending_responses": "جارٍ إرسال الردود...",
|
||||
"takes": "يأخذ",
|
||||
"terms_of_service": "شروط الخدمة",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "لا يمكن الوصول إلى الخوادم في الوقت الحالي.",
|
||||
"they_will_be_redirected_immediately": "سيتم إعادة توجيههم فورًا",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "قم بتحميل الملفات بالنقر عليها أو سحبها إلى هنا",
|
||||
"uploading": "جارٍ التحميل",
|
||||
"x_minutes": "{count, plural, one {دقيقة واحدة} two {دقيقتان} few {{count} دقائق} many {{count} دقيقة} other {{count} دقيقة}}",
|
||||
"x_plus_minutes": "{count}+ دقيقة",
|
||||
"you_have_selected_x_date": "لقد اخترت {date}",
|
||||
"you_have_successfully_uploaded_the_file": "لقد قمت بتحميل الملف {fileName} بنجاح",
|
||||
"your_feedback_is_stuck": "تعليقك عالق :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "يرجى ملء هذا الحقل",
|
||||
"please_rank_all_items_before_submitting": "يرجى ترتيب جميع العناصر قبل الإرسال",
|
||||
"please_select_a_date": "يرجى اختيار تاريخ",
|
||||
"please_select_an_option": "يرجى اختيار خيار",
|
||||
"please_upload_a_file": "يرجى تحميل ملف",
|
||||
"recaptcha_error": {
|
||||
"message": "تعذر إرسال ردك لأنه تم تصنيفه كنشاط آلي. إذا كنت تتنفس، يرجى المحاولة مرة أخرى.",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "anwenden",
|
||||
"auto_close_wrapper": "Automatisches Schließen",
|
||||
"back": "Zurück",
|
||||
"click_or_drag_to_upload_files": "Klicken oder ziehen Sie, um Dateien hochzuladen.",
|
||||
"close_survey": "Umfrage schließen",
|
||||
"company_logo": "Firmenlogo",
|
||||
"delete_file": "Datei löschen",
|
||||
"file_upload": "Datei-Upload",
|
||||
"finish": "Fertig",
|
||||
"language_switch": "Sprachwechsel",
|
||||
"less_than_x_minutes": "{count, plural, one {weniger als 1 Minute} other {weniger als {count} Minuten}}",
|
||||
"move_down": "{item} nach unten verschieben",
|
||||
"move_up": "{item} nach oben verschieben",
|
||||
"next": "Weiter",
|
||||
"open_in_new_tab": "In neuem Tab öffnen",
|
||||
"optional": "Optional",
|
||||
"options": "Optionen",
|
||||
"people_responded": "{count, plural, one {1 Person hat geantwortet} other {{count} Personen haben geantwortet}}",
|
||||
"please_retry_now_or_try_again_later": "Bitte versuchen Sie es jetzt erneut oder später noch einmal.",
|
||||
"please_specify": "Bitte angeben",
|
||||
"powered_by": "Bereitgestellt von",
|
||||
"privacy_policy": "Datenschutzrichtlinie",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Geschützt durch reCAPTCHA und die Google",
|
||||
"question": "Frage",
|
||||
"question_video": "Fragevideo",
|
||||
"ranking_items": "Ranking-Elemente",
|
||||
"required": "Erforderlich",
|
||||
"respondents_will_not_see_this_card": "Befragte werden diese Karte nicht sehen",
|
||||
"retry": "Wiederholen",
|
||||
"retrying": "Wird wiederholt...",
|
||||
"select_a_date": "Datum auswählen",
|
||||
"select_for_ranking": "{item} für Ranking auswählen",
|
||||
"retrying": "Erneuter Versuch...",
|
||||
"sending_responses": "Antworten werden gesendet...",
|
||||
"takes": "Dauert",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
|
||||
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Laden Sie Dateien hoch, indem Sie sie hier anklicken oder hierher ziehen",
|
||||
"uploading": "Wird hochgeladen",
|
||||
"x_minutes": "{count, plural, one {1 Minute} other {{count} Minuten}}",
|
||||
"x_plus_minutes": "{count}+ Minuten",
|
||||
"you_have_selected_x_date": "Sie haben {date} ausgewählt",
|
||||
"you_have_successfully_uploaded_the_file": "Sie haben die Datei {fileName} erfolgreich hochgeladen",
|
||||
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
|
||||
"please_rank_all_items_before_submitting": "Bitte ordnen Sie alle Elemente vor dem Absenden",
|
||||
"please_select_a_date": "Bitte wählen Sie ein Datum aus",
|
||||
"please_select_an_option": "Bitte wählen Sie eine Option",
|
||||
"please_upload_a_file": "Bitte laden Sie eine Datei hoch",
|
||||
"recaptcha_error": {
|
||||
"message": "Ihre Antwort konnte nicht übermittelt werden, da sie als automatisierte Aktivität eingestuft wurde. Wenn Sie atmen, versuchen Sie es bitte erneut.",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "apply",
|
||||
"auto_close_wrapper": "Auto close wrapper",
|
||||
"back": "Back",
|
||||
"click_or_drag_to_upload_files": "Click or drag to upload files.",
|
||||
"close_survey": "Close survey",
|
||||
"company_logo": "Company Logo",
|
||||
"delete_file": "Delete file",
|
||||
"file_upload": "File upload",
|
||||
"finish": "Finish",
|
||||
"language_switch": "Language switch",
|
||||
"less_than_x_minutes": "{count, plural, one {less than 1 minute} other {less than {count} minutes}}",
|
||||
"move_down": "Move {item} down",
|
||||
"move_up": "Move {item} up",
|
||||
"next": "Next",
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"optional": "Optional",
|
||||
"options": "Options",
|
||||
"people_responded": "{count, plural, one {1 person responded} other {{count} people responded}}",
|
||||
"please_retry_now_or_try_again_later": "Please retry now or try again later.",
|
||||
"please_specify": "Please specify",
|
||||
"powered_by": "Powered by",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Protected by reCAPTCHA and the Google",
|
||||
"question": "Question",
|
||||
"question_video": "Question Video",
|
||||
"ranking_items": "Ranking Items",
|
||||
"required": "Required",
|
||||
"respondents_will_not_see_this_card": "Respondents will not see this card",
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying...",
|
||||
"select_a_date": "Select a date",
|
||||
"select_for_ranking": "Select {item} for ranking",
|
||||
"sending_responses": "Sending responses...",
|
||||
"takes": "Takes",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
|
||||
"they_will_be_redirected_immediately": "They will be redirected immediately",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Upload files by clicking or dragging them here",
|
||||
"uploading": "Uploading",
|
||||
"x_minutes": "{count, plural, one {1 minute} other {{count} minutes}}",
|
||||
"x_plus_minutes": "{count}+ minutes",
|
||||
"you_have_selected_x_date": "You have selected {date}",
|
||||
"you_have_successfully_uploaded_the_file": "You've successfully uploaded the file {fileName}",
|
||||
"your_feedback_is_stuck": "Your feedback is stuck :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "Please fill out this field",
|
||||
"please_rank_all_items_before_submitting": "Please rank all items before submitting",
|
||||
"please_select_a_date": "Please select a date",
|
||||
"please_select_an_option": "Please select an option",
|
||||
"please_upload_a_file": "Please upload a file",
|
||||
"recaptcha_error": {
|
||||
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "aplicar",
|
||||
"auto_close_wrapper": "Cerrar automáticamente",
|
||||
"back": "Atrás",
|
||||
"click_or_drag_to_upload_files": "Haz clic o arrastra para subir archivos.",
|
||||
"close_survey": "Cerrar encuesta",
|
||||
"company_logo": "Logo de la empresa",
|
||||
"delete_file": "Eliminar archivo",
|
||||
"file_upload": "Subida de archivos",
|
||||
"finish": "Finalizar",
|
||||
"language_switch": "Cambio de idioma",
|
||||
"less_than_x_minutes": "{count, plural, one {menos de 1 minuto} other {menos de {count} minutos}}",
|
||||
"move_down": "Mover {item} hacia abajo",
|
||||
"move_up": "Mover {item} hacia arriba",
|
||||
"next": "Siguiente",
|
||||
"open_in_new_tab": "Abrir en nueva pestaña",
|
||||
"optional": "Opcional",
|
||||
"options": "Opciones",
|
||||
"people_responded": "{count, plural, one {1 persona respondió} other {{count} personas respondieron}}",
|
||||
"please_retry_now_or_try_again_later": "Por favor, inténtalo ahora o prueba más tarde.",
|
||||
"please_specify": "Por favor especifique",
|
||||
"powered_by": "Desarrollado por",
|
||||
"privacy_policy": "Política de privacidad",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Protegido por reCAPTCHA y Google",
|
||||
"question": "Pregunta",
|
||||
"question_video": "Video de la pregunta",
|
||||
"ranking_items": "Elementos de clasificación",
|
||||
"required": "Obligatorio",
|
||||
"respondents_will_not_see_this_card": "Los encuestados no verán esta tarjeta",
|
||||
"retry": "Reintentar",
|
||||
"retrying": "Reintentando...",
|
||||
"select_a_date": "Seleccionar una fecha",
|
||||
"select_for_ranking": "Seleccionar {item} para clasificación",
|
||||
"sending_responses": "Enviando respuestas...",
|
||||
"takes": "Tomas",
|
||||
"terms_of_service": "Términos de servicio",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Los servidores no pueden ser alcanzados en este momento.",
|
||||
"they_will_be_redirected_immediately": "Serán redirigidos inmediatamente",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Sube archivos haciendo clic o arrastrándolos aquí",
|
||||
"uploading": "Subiendo",
|
||||
"x_minutes": "{count, plural, one {1 minuto} other {{count} minutos}}",
|
||||
"x_plus_minutes": "{count}+ minutos",
|
||||
"you_have_selected_x_date": "Has seleccionado {date}",
|
||||
"you_have_successfully_uploaded_the_file": "Has subido correctamente el archivo {fileName}",
|
||||
"your_feedback_is_stuck": "Tu feedback está atascado :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "Por favor, complete este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, clasifique todos los elementos antes de enviar",
|
||||
"please_select_a_date": "Por favor, seleccione una fecha",
|
||||
"please_select_an_option": "Por favor selecciona una opción",
|
||||
"please_upload_a_file": "Por favor, suba un archivo",
|
||||
"recaptcha_error": {
|
||||
"message": "Su respuesta no pudo ser enviada porque fue marcada como actividad automatizada. Si respira, por favor inténtelo de nuevo.",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "appliquer",
|
||||
"auto_close_wrapper": "Fermeture automatique du wrapper",
|
||||
"back": "Retour",
|
||||
"click_or_drag_to_upload_files": "Cliquez ou glissez pour télécharger des fichiers.",
|
||||
"close_survey": "Fermer le sondage",
|
||||
"company_logo": "Logo de l'entreprise",
|
||||
"delete_file": "Supprimer le fichier",
|
||||
"file_upload": "Téléchargement de fichier",
|
||||
"finish": "Terminer",
|
||||
"language_switch": "Changement de langue",
|
||||
"less_than_x_minutes": "{count, plural, one {moins d'une minute} other {moins de {count} minutes}}",
|
||||
"move_down": "Déplacer {item} vers le bas",
|
||||
"move_up": "Déplacer {item} vers le haut",
|
||||
"next": "Suivant",
|
||||
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
||||
"optional": "Facultatif",
|
||||
"options": "Options",
|
||||
"people_responded": "{count, plural, one {1 personne a répondu} other {{count} personnes ont répondu}}",
|
||||
"please_retry_now_or_try_again_later": "Veuillez réessayer maintenant ou réessayer plus tard.",
|
||||
"please_specify": "Veuillez préciser",
|
||||
"powered_by": "Propulsé par",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Protégé par reCAPTCHA et Google",
|
||||
"question": "Question",
|
||||
"question_video": "Vidéo de la question",
|
||||
"ranking_items": "Éléments de classement",
|
||||
"required": "Obligatoire",
|
||||
"respondents_will_not_see_this_card": "Les répondants ne verront pas cette carte",
|
||||
"retry": "Réessayer",
|
||||
"retrying": "Nouvelle tentative...",
|
||||
"select_a_date": "Sélectionner une date",
|
||||
"select_for_ranking": "Sélectionner {item} pour le classement",
|
||||
"sending_responses": "Envoi des réponses...",
|
||||
"takes": "Prises",
|
||||
"terms_of_service": "Conditions d'utilisation",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Les serveurs ne sont pas accessibles pour le moment.",
|
||||
"they_will_be_redirected_immediately": "Ils seront redirigés immédiatement",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Téléchargez des fichiers en cliquant ou en les faisant glisser ici",
|
||||
"uploading": "Téléchargement en cours",
|
||||
"x_minutes": "{count, plural, one {1 minute} other {{count} minutes}}",
|
||||
"x_plus_minutes": "{count}+ minutes",
|
||||
"you_have_selected_x_date": "Vous avez sélectionné {date}",
|
||||
"you_have_successfully_uploaded_the_file": "Vous avez téléchargé le fichier {fileName} avec succès",
|
||||
"your_feedback_is_stuck": "Votre feedback est bloqué :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "Veuillez remplir ce champ",
|
||||
"please_rank_all_items_before_submitting": "Veuillez classer tous les éléments avant de soumettre",
|
||||
"please_select_a_date": "Veuillez sélectionner une date",
|
||||
"please_select_an_option": "Veuillez sélectionner une option",
|
||||
"please_upload_a_file": "Veuillez télécharger un fichier",
|
||||
"recaptcha_error": {
|
||||
"message": "Votre réponse n'a pas pu être soumise car elle a été signalée comme une activité automatisée. Si vous respirez, veuillez réessayer.",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "लागू करें",
|
||||
"auto_close_wrapper": "स्वचालित बंद रैपर",
|
||||
"back": "वापस",
|
||||
"click_or_drag_to_upload_files": "फ़ाइलें अपलोड करने के लिए क्लिक करें या खींचें।",
|
||||
"close_survey": "सर्वेक्षण बंद करें",
|
||||
"company_logo": "कंपनी लोगो",
|
||||
"delete_file": "फ़ाइल हटाएं",
|
||||
"file_upload": "फ़ाइल अपलोड",
|
||||
"finish": "समाप्त करें",
|
||||
"language_switch": "भाषा बदलें",
|
||||
"less_than_x_minutes": "{count, plural, one {1 मिनट से कम} other {{count} मिनट से कम}}",
|
||||
"move_down": "{item} को नीचे ले जाएं",
|
||||
"move_up": "{item} को ऊपर ले जाएं",
|
||||
"next": "अगला",
|
||||
"open_in_new_tab": "नए टैब में खोलें",
|
||||
"optional": "वैकल्पिक",
|
||||
"options": "विकल्प",
|
||||
"people_responded": "{count, plural, one {1 व्यक्ति ने जवाब दिया} other {{count} लोगों ने जवाब दिया}}",
|
||||
"please_retry_now_or_try_again_later": "कृपया अभी पुनः प्रयास करें या बाद में फिर से प्रयास करें।",
|
||||
"please_specify": "कृपया निर्दिष्ट करें",
|
||||
"powered_by": "द्वारा संचालित",
|
||||
"privacy_policy": "गोपनीयता नीति",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "reCAPTCHA और Google द्वारा संरक्षित",
|
||||
"question": "प्रश्न",
|
||||
"question_video": "प्रश्न वीडियो",
|
||||
"ranking_items": "रैंकिंग आइटम",
|
||||
"required": "आवश्यक",
|
||||
"respondents_will_not_see_this_card": "उत्तरदाता इस कार्ड को नहीं देखेंगे",
|
||||
"retry": "पुनः प्रयास करें",
|
||||
"retrying": "पुनः प्रयास कर रहे हैं...",
|
||||
"select_a_date": "एक तिथि चुनें",
|
||||
"select_for_ranking": "रैंकिंग के लिए {item} चुनें",
|
||||
"sending_responses": "प्रतिक्रियाएँ भेज रहे हैं...",
|
||||
"takes": "लेता है",
|
||||
"terms_of_service": "सेवा की शर्तें",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "इस समय सर्वर तक पहुंचा नहीं जा सकता है।",
|
||||
"they_will_be_redirected_immediately": "उन्हें तुरंत रीडायरेक्ट किया जाएगा",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "क्लिक करके या यहां खींचकर फ़ाइलें अपलोड करें",
|
||||
"uploading": "अपलोड हो रहा है",
|
||||
"x_minutes": "{count, plural, one {1 मिनट} other {{count} मिनट}}",
|
||||
"x_plus_minutes": "{count}+ मिनट",
|
||||
"you_have_selected_x_date": "आपने {date} चुना है",
|
||||
"you_have_successfully_uploaded_the_file": "आपने {fileName} फ़ाइल सफलतापूर्वक अपलोड कर दी है",
|
||||
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "कृपया इस फील्ड को भरें",
|
||||
"please_rank_all_items_before_submitting": "जमा करने से पहले कृपया सभी आइटम्स को रैंक करें",
|
||||
"please_select_a_date": "कृपया एक तारीख चुनें",
|
||||
"please_select_an_option": "कृपया एक विकल्प चुनें",
|
||||
"please_upload_a_file": "कृपया एक फाइल अपलोड करें",
|
||||
"recaptcha_error": {
|
||||
"message": "आपका प्रतिसाद जमा नहीं किया जा सका क्योंकि इसे स्वचालित गतिविधि के रूप में चिह्नित किया गया था। यदि आप सांस लेते हैं, तो कृपया पुनः प्रयास करें।",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "applica",
|
||||
"auto_close_wrapper": "Wrapper a chiusura automatica",
|
||||
"back": "Indietro",
|
||||
"click_or_drag_to_upload_files": "Clicca o trascina per caricare i file.",
|
||||
"close_survey": "Chiudi sondaggio",
|
||||
"company_logo": "Logo aziendale",
|
||||
"delete_file": "Elimina file",
|
||||
"file_upload": "Caricamento file",
|
||||
"finish": "Fine",
|
||||
"language_switch": "Cambio lingua",
|
||||
"less_than_x_minutes": "{count, plural, one {meno di 1 minuto} other {meno di {count} minuti}}",
|
||||
"move_down": "Sposta {item} giù",
|
||||
"move_up": "Sposta {item} su",
|
||||
"next": "Avanti",
|
||||
"open_in_new_tab": "Apri in una nuova scheda",
|
||||
"optional": "Facoltativo",
|
||||
"options": "Opzioni",
|
||||
"people_responded": "{count, plural, one {1 persona ha risposto} other {{count} persone hanno risposto}}",
|
||||
"please_retry_now_or_try_again_later": "Riprova ora o più tardi.",
|
||||
"please_specify": "Si prega di specificare",
|
||||
"powered_by": "Offerto da",
|
||||
"privacy_policy": "Informativa sulla privacy",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Protetto da reCAPTCHA e da Google",
|
||||
"question": "Domanda",
|
||||
"question_video": "Video della domanda",
|
||||
"ranking_items": "Elementi di classifica",
|
||||
"required": "Obbligatorio",
|
||||
"respondents_will_not_see_this_card": "I rispondenti non vedranno questa scheda",
|
||||
"retry": "Riprova",
|
||||
"retrying": "Riprovando...",
|
||||
"select_a_date": "Seleziona una data",
|
||||
"select_for_ranking": "Seleziona {item} per la classifica",
|
||||
"sending_responses": "Invio risposte in corso...",
|
||||
"takes": "Riprese",
|
||||
"terms_of_service": "Termini di servizio",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "I server non sono raggiungibili al momento.",
|
||||
"they_will_be_redirected_immediately": "Saranno reindirizzati immediatamente",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Carica file cliccando o trascinandoli qui",
|
||||
"uploading": "Caricamento in corso",
|
||||
"x_minutes": "{count, plural, one {1 minuto} other {{count} minuti}}",
|
||||
"x_plus_minutes": "{count}+ minuti",
|
||||
"you_have_selected_x_date": "Hai selezionato {date}",
|
||||
"you_have_successfully_uploaded_the_file": "Hai caricato con successo il file {fileName}",
|
||||
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "Compila questo campo",
|
||||
"please_rank_all_items_before_submitting": "Classifica tutti gli elementi prima di inviare",
|
||||
"please_select_a_date": "Seleziona una data",
|
||||
"please_select_an_option": "Seleziona un'opzione",
|
||||
"please_upload_a_file": "Carica un file",
|
||||
"recaptcha_error": {
|
||||
"message": "La tua risposta non può essere inviata perché è stata segnalata come attività automatizzata. Se respiri, riprova.",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "適用",
|
||||
"auto_close_wrapper": "自動クローズラッパー",
|
||||
"back": "戻る",
|
||||
"click_or_drag_to_upload_files": "クリックまたはドラッグしてファイルをアップロード。",
|
||||
"close_survey": "アンケートを閉じる",
|
||||
"company_logo": "会社ロゴ",
|
||||
"delete_file": "ファイルを削除",
|
||||
"file_upload": "ファイルアップロード",
|
||||
"finish": "完了",
|
||||
"language_switch": "言語切替",
|
||||
"less_than_x_minutes": "{count, plural, other {{count}分未満}}",
|
||||
"move_down": "{item}を下に移動",
|
||||
"move_up": "{item}を上に移動",
|
||||
"next": "次へ",
|
||||
"open_in_new_tab": "新しいタブで開く",
|
||||
"optional": "任意",
|
||||
"options": "オプション",
|
||||
"people_responded": "{count, plural, other {{count}人が回答しました}}",
|
||||
"please_retry_now_or_try_again_later": "今すぐ再試行するか、後でもう一度お試しください。",
|
||||
"please_specify": "具体的に記入してください",
|
||||
"powered_by": "提供:",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "reCAPTCHAとGoogleによって保護されています",
|
||||
"question": "質問",
|
||||
"question_video": "質問ビデオ",
|
||||
"ranking_items": "ランキング項目",
|
||||
"required": "必須",
|
||||
"respondents_will_not_see_this_card": "回答者はこのカードを見ることができません",
|
||||
"retry": "再試行",
|
||||
"retrying": "再試行中...",
|
||||
"select_a_date": "日付を選択",
|
||||
"select_for_ranking": "ランキング用に{item}を選択",
|
||||
"sending_responses": "回答を送信中...",
|
||||
"takes": "所要時間",
|
||||
"terms_of_service": "利用規約",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "現在サーバーに接続できません。",
|
||||
"they_will_be_redirected_immediately": "すぐにリダイレクトされます",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "クリックまたはドラッグしてファイルをアップロード",
|
||||
"uploading": "アップロード中",
|
||||
"x_minutes": "{count, plural, one {1分} other {{count}分}}",
|
||||
"x_plus_minutes": "{count}分以上",
|
||||
"you_have_selected_x_date": "{date}を選択しました",
|
||||
"you_have_successfully_uploaded_the_file": "ファイル{fileName}のアップロードに成功しました",
|
||||
"your_feedback_is_stuck": "フィードバックが送信できません :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "このフィールドに入力してください",
|
||||
"please_rank_all_items_before_submitting": "送信する前にすべての項目をランク付けしてください",
|
||||
"please_select_a_date": "日付を選択してください",
|
||||
"please_select_an_option": "オプションを選択してください",
|
||||
"please_upload_a_file": "ファイルをアップロードしてください",
|
||||
"recaptcha_error": {
|
||||
"message": "自動化された活動としてフラグが立てられたため、回答を送信できませんでした。人間の方は、もう一度お試しください。",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "Toepassen",
|
||||
"auto_close_wrapper": "Automatisch sluitende wrapper",
|
||||
"back": "Terug",
|
||||
"click_or_drag_to_upload_files": "Klik of sleep om bestanden te uploaden.",
|
||||
"close_survey": "Enquête sluiten",
|
||||
"company_logo": "Bedrijfslogo",
|
||||
"delete_file": "Bestand verwijderen",
|
||||
"file_upload": "Bestand uploaden",
|
||||
"finish": "Voltooien",
|
||||
"language_switch": "Taalschakelaar",
|
||||
"less_than_x_minutes": "{count, plural, one {minder dan 1 minuut} other {minder dan {count} minuten}}",
|
||||
"move_down": "Verplaats {item} naar beneden",
|
||||
"move_up": "Verplaats {item} omhoog",
|
||||
"next": "Volgende",
|
||||
"open_in_new_tab": "Openen in nieuw tabblad",
|
||||
"optional": "Optioneel",
|
||||
"options": "Opties",
|
||||
"people_responded": "{count, plural, one {1 persoon heeft gereageerd} other {{count} mensen hebben gereageerd}}",
|
||||
"please_retry_now_or_try_again_later": "Probeer het nu opnieuw of probeer het later opnieuw.",
|
||||
"please_specify": "Graag specificeren",
|
||||
"powered_by": "Aangedreven door",
|
||||
"privacy_policy": "Privacybeleid",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Beschermd door reCAPTCHA en Google",
|
||||
"question": "Vraag",
|
||||
"question_video": "Vraagvideo",
|
||||
"ranking_items": "Items rangschikken",
|
||||
"required": "Verplicht",
|
||||
"respondents_will_not_see_this_card": "Respondenten zien deze kaart niet",
|
||||
"retry": "Opnieuw proberen",
|
||||
"retrying": "Opnieuw proberen...",
|
||||
"select_a_date": "Selecteer een datum",
|
||||
"select_for_ranking": "Selecteer {item} voor rangschikking",
|
||||
"sending_responses": "Reacties verzenden...",
|
||||
"takes": "Neemt",
|
||||
"terms_of_service": "Servicevoorwaarden",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "De servers zijn momenteel niet bereikbaar.",
|
||||
"they_will_be_redirected_immediately": "Ze worden onmiddellijk doorgestuurd",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Upload bestanden door ze hierheen te klikken of te slepen",
|
||||
"uploading": "Uploaden",
|
||||
"x_minutes": "{count, plural, one {1 minuut} other {{count} minuten}}",
|
||||
"x_plus_minutes": "{count}+ minuten",
|
||||
"you_have_selected_x_date": "Je hebt {date} geselecteerd",
|
||||
"you_have_successfully_uploaded_the_file": "Je hebt het bestand {fileName} succesvol geüpload",
|
||||
"your_feedback_is_stuck": "Je feedback blijft hangen :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "Vul dit veld in",
|
||||
"please_rank_all_items_before_submitting": "Rangschik alle items voordat u ze verzendt",
|
||||
"please_select_a_date": "Selecteer een datum",
|
||||
"please_select_an_option": "Selecteer een optie",
|
||||
"please_upload_a_file": "Upload een bestand",
|
||||
"recaptcha_error": {
|
||||
"message": "Uw reactie kan niet worden verzonden omdat deze is gemarkeerd als geautomatiseerde activiteit. Als u ademhaalt, probeer het dan opnieuw.",
|
||||
|
||||
@@ -4,44 +4,32 @@
|
||||
"apply": "aplicar",
|
||||
"auto_close_wrapper": "Fechamento automático do wrapper",
|
||||
"back": "Voltar",
|
||||
"click_or_drag_to_upload_files": "Clique ou arraste para carregar arquivos.",
|
||||
"close_survey": "Fechar pesquisa",
|
||||
"company_logo": "Logo da empresa",
|
||||
"delete_file": "Excluir arquivo",
|
||||
"file_upload": "Carregamento de arquivo",
|
||||
"finish": "Finalizar",
|
||||
"language_switch": "Alternar idioma",
|
||||
"less_than_x_minutes": "{count, plural, one {menos de 1 minuto} other {menos de {count} minutos}}",
|
||||
"move_down": "Mover {item} para baixo",
|
||||
"move_up": "Mover {item} para cima",
|
||||
"next": "Próximo",
|
||||
"open_in_new_tab": "Abrir em nova aba",
|
||||
"optional": "Opcional",
|
||||
"options": "Opções",
|
||||
"people_responded": "{count, plural, one {1 pessoa respondeu} other {{count} pessoas responderam}}",
|
||||
"please_retry_now_or_try_again_later": "Por favor, tente novamente agora ou mais tarde.",
|
||||
"please_specify": "Por favor, especifique",
|
||||
"powered_by": "Desenvolvido por",
|
||||
"privacy_policy": "Política de privacidade",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Protegido pelo reCAPTCHA e o Google",
|
||||
"question": "Pergunta",
|
||||
"question_video": "Vídeo da pergunta",
|
||||
"ranking_items": "Itens de classificação",
|
||||
"required": "Obrigatório",
|
||||
"respondents_will_not_see_this_card": "Os respondentes não verão este cartão",
|
||||
"retry": "Tentar novamente",
|
||||
"retrying": "Tentando novamente...",
|
||||
"select_a_date": "Selecione uma data",
|
||||
"select_for_ranking": "Selecione {item} para classificação",
|
||||
"sending_responses": "Enviando respostas...",
|
||||
"takes": "Leva",
|
||||
"terms_of_service": "Termos de serviço",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Os servidores não podem ser alcançados no momento.",
|
||||
"they_will_be_redirected_immediately": "Eles serão redirecionados imediatamente",
|
||||
"upload_files_by_clicking_or_dragging_them_here": "Carregue arquivos clicando ou arrastando-os aqui",
|
||||
"uploading": "Carregando",
|
||||
"x_minutes": "{count, plural, one {1 minuto} other {{count} minutos}}",
|
||||
"x_plus_minutes": "{count}+ minutos",
|
||||
"you_have_selected_x_date": "Você selecionou {date}",
|
||||
"you_have_successfully_uploaded_the_file": "Você carregou o arquivo {fileName} com sucesso",
|
||||
"your_feedback_is_stuck": "Seu feedback está preso :("
|
||||
},
|
||||
"errors": {
|
||||
@@ -65,6 +53,7 @@
|
||||
"please_fill_out_this_field": "Por favor, preencha este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, classifique todos os itens antes de enviar",
|
||||
"please_select_a_date": "Por favor, selecione uma data",
|
||||
"please_select_an_option": "Por favor, selecione uma opção",
|
||||
"please_upload_a_file": "Por favor, carregue um arquivo",
|
||||
"recaptcha_error": {
|
||||
"message": "Sua resposta não pôde ser enviada porque foi sinalizada como atividade automatizada. Se você respira, por favor tente novamente.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user