mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 16:16:21 -06:00
chore: new question components (#6947)
Co-authored-by: Matti Nannt <matti@formbricks.com> Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
committed by
GitHub
parent
f1b9f45f18
commit
6f85e57d1a
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,12 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join } from "path";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
@@ -29,5 +33,92 @@ const config: StorybookConfig = {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
options: {},
|
||||
},
|
||||
async viteFinal(config) {
|
||||
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
|
||||
const webPath = resolve(__dirname, "../../web");
|
||||
const rootPath = resolve(__dirname, "../../../");
|
||||
|
||||
// Configure server to allow files from outside the storybook directory
|
||||
config.server = config.server || {};
|
||||
config.server.fs = {
|
||||
...config.server.fs,
|
||||
allow: [...(config.server.fs?.allow || []), rootPath],
|
||||
};
|
||||
|
||||
// Create a more robust plugin to handle @/ alias resolution
|
||||
const aliasPlugin: Plugin = {
|
||||
name: "resolve-path-alias",
|
||||
enforce: "pre",
|
||||
resolveId(id, importer) {
|
||||
if (!id.startsWith("@/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathWithoutAlias = id.replace(/^@\//, "");
|
||||
const normalizedImporter = importer ? importer.replace(/\\/g, "/") : "";
|
||||
|
||||
// Determine base path based on importer location
|
||||
let basePath: string | null = null;
|
||||
|
||||
if (normalizedImporter.includes("packages/survey-ui")) {
|
||||
basePath = surveyUiPath;
|
||||
} else if (normalizedImporter.includes("apps/web") || normalizedImporter.includes("web/modules")) {
|
||||
basePath = webPath;
|
||||
}
|
||||
|
||||
// If we can't determine from importer, try to find the file
|
||||
if (!basePath) {
|
||||
// Try survey-ui first
|
||||
const surveyUiResolved = resolve(surveyUiPath, pathWithoutAlias);
|
||||
const fs = require("fs");
|
||||
const extensions = ["", ".ts", ".tsx", ".js", ".jsx"];
|
||||
|
||||
for (const ext of extensions) {
|
||||
if (fs.existsSync(surveyUiResolved + ext)) {
|
||||
return surveyUiResolved + ext;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web
|
||||
basePath = webPath;
|
||||
}
|
||||
|
||||
const resolved = resolve(basePath, pathWithoutAlias);
|
||||
|
||||
// Try to resolve with extensions
|
||||
const fs = require("fs");
|
||||
const extensions = ["", ".ts", ".tsx", ".js", ".jsx"];
|
||||
|
||||
for (const ext of extensions) {
|
||||
const withExt = resolved + ext;
|
||||
if (fs.existsSync(withExt)) {
|
||||
return withExt;
|
||||
}
|
||||
}
|
||||
|
||||
// Return without extension - Vite will handle it
|
||||
return resolved;
|
||||
},
|
||||
};
|
||||
|
||||
// Add the plugin
|
||||
config.plugins = [aliasPlugin, ...(config.plugins || [])];
|
||||
|
||||
// Configure resolve options
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.dedupe = config.resolve.dedupe || [];
|
||||
|
||||
// Keep existing aliases but remove @/ since plugin handles it
|
||||
const existingAlias = config.resolve.alias || {};
|
||||
if (typeof existingAlias === "object" && !Array.isArray(existingAlias)) {
|
||||
const aliasObj = existingAlias as Record<string, string>;
|
||||
const { "@": _, ...otherAliases } = aliasObj;
|
||||
config.resolve.alias = {
|
||||
...otherAliases,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
import base from "../web/tailwind.config";
|
||||
import surveyUi from "../../packages/survey-ui/tailwind.config";
|
||||
|
||||
export default {
|
||||
...base,
|
||||
@@ -9,4 +10,11 @@ export default {
|
||||
"../web/modules/ui/**/*.{js,ts,jsx,tsx}",
|
||||
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
...base.theme,
|
||||
extend: {
|
||||
...base.theme?.extend,
|
||||
...surveyUi.theme?.extend,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
"i18n:validate": "pnpm scan-translations"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.2",
|
||||
"react-dom": "19.1.2"
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure/microsoft-playwright-testing": "1.0.0-beta.7",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
ignorePatterns: ["**/*.stories.tsx", "**/*.stories.ts"],
|
||||
};
|
||||
|
||||
|
||||
@@ -39,26 +39,32 @@
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "0.8.2",
|
||||
"@radix-ui/react-checkbox": "1.3.1",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.14",
|
||||
"@radix-ui/react-label": "2.1.6",
|
||||
"@radix-ui/react-popover": "1.1.13",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"lucide-react": "0.555.0"
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"isomorphic-dompurify": "^2.33.0",
|
||||
"react-day-picker": "9.6.7",
|
||||
"tailwind-merge": "3.2.0",
|
||||
"lucide-react": "0.507.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@storybook/react": "^8.5.4",
|
||||
"@storybook/react-vite": "^8.5.4",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"@types/react": "19.2.1",
|
||||
"@types/react-dom": "19.2.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"tailwindcss": "^4.1.1",
|
||||
"terser": "5.39.1",
|
||||
|
||||
186
packages/survey-ui/src/components/elements/consent.stories.tsx
Normal file
186
packages/survey-ui/src/components/elements/consent.stories.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Consent, type ConsentProps } from "./consent";
|
||||
|
||||
type StoryProps = ConsentProps & Partial<BaseStylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Consent",
|
||||
component: Consent,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A consent element that displays a checkbox for users to accept terms, conditions, or agreements.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
checkboxLabel: {
|
||||
control: "text",
|
||||
description: "Label text for the consent checkbox",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "boolean",
|
||||
description: "Whether consent is checked",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Consent),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "consent-1",
|
||||
inputId: "consent-input-1",
|
||||
headline: "Terms and Conditions",
|
||||
description: "Please read and accept the terms",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontSize",
|
||||
"inputFontWeight",
|
||||
"inputWidth",
|
||||
"inputBorderRadius",
|
||||
"inputPaddingX",
|
||||
"inputPaddingY",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps & Record<string, unknown>>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "consent-1",
|
||||
inputId: "consent-input-1",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "consent-2",
|
||||
inputId: "consent-input-2",
|
||||
headline: "Terms and Conditions",
|
||||
description: "Please read and accept the terms to continue",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithConsent: Story = {
|
||||
args: {
|
||||
elementId: "consent-3",
|
||||
inputId: "consent-input-3",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
value: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "consent-4",
|
||||
inputId: "consent-input-4",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
required: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "consent-5",
|
||||
inputId: "consent-input-5",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
required: true,
|
||||
errorMessage: "You must accept the terms to continue",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "consent-6",
|
||||
inputId: "consent-input-6",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
value: true,
|
||||
disabled: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "consent-rtl",
|
||||
inputId: "consent-input-rtl",
|
||||
headline: "الشروط والأحكام",
|
||||
description: "يرجى قراءة الشروط والموافقة عليها",
|
||||
checkboxLabel: "أوافق على الشروط والأحكام",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithConsent: Story = {
|
||||
args: {
|
||||
elementId: "consent-rtl-checked",
|
||||
inputId: "consent-input-rtl-checked",
|
||||
headline: "الشروط والأحكام",
|
||||
checkboxLabel: "أوافق على الشروط والأحكام",
|
||||
value: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Consent
|
||||
elementId="consent-1"
|
||||
inputId="consent-input-1"
|
||||
headline="Terms and Conditions"
|
||||
description="Please read and accept the terms"
|
||||
checkboxLabel="I agree to the terms and conditions"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Consent
|
||||
elementId="consent-2"
|
||||
inputId="consent-input-2"
|
||||
headline="Privacy Policy"
|
||||
description="Please review our privacy policy"
|
||||
checkboxLabel="I agree to the privacy policy"
|
||||
value
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
98
packages/survey-ui/src/components/elements/consent.tsx
Normal file
98
packages/survey-ui/src/components/elements/consent.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as React from "react";
|
||||
import { Checkbox } from "@/components/general/checkbox";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Props for the Consent element component
|
||||
*/
|
||||
export interface ConsentProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the consent checkbox */
|
||||
inputId: string;
|
||||
/** Label text for the consent checkbox */
|
||||
checkboxLabel: string;
|
||||
/** Whether consent is checked */
|
||||
value?: boolean;
|
||||
/** Callback function called when consent changes */
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the checkbox is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Consent({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
checkboxLabel,
|
||||
value = false,
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: ConsentProps): React.JSX.Element {
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? "", checkboxLabel],
|
||||
});
|
||||
|
||||
const handleCheckboxChange = (checked: boolean): void => {
|
||||
if (disabled) return;
|
||||
onChange(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={cn(
|
||||
"bg-input-bg border-input-border text-input-text w-input px-input-x py-input-y rounded-input flex cursor-pointer items-center gap-3 border p-4 transition-colors",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 font-fontWeight focus-within:shadow-sm",
|
||||
errorMessage && "border-destructive",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
dir={detectedDir}>
|
||||
<Checkbox
|
||||
id={inputId}
|
||||
checked={value}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
aria-required={required}
|
||||
/>
|
||||
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
|
||||
<span
|
||||
className="text-input font-input-weight text-input-text flex-1 leading-none"
|
||||
dir={detectedDir}>
|
||||
{checkboxLabel}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Consent };
|
||||
185
packages/survey-ui/src/components/elements/cta.stories.tsx
Normal file
185
packages/survey-ui/src/components/elements/cta.stories.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type ButtonStylingOptions,
|
||||
buttonStylingArgTypes,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { CTA, type CTAProps } from "./cta";
|
||||
|
||||
type StoryProps = CTAProps & Partial<BaseStylingOptions & ButtonStylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/CTA",
|
||||
component: CTA,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A Call-to-Action (CTA) element that displays a button. Can optionally open an external URL when clicked.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
buttonLabel: {
|
||||
control: "text",
|
||||
description: "Label text for the CTA button",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonUrl: {
|
||||
control: "text",
|
||||
description: "URL to open when button is clicked (if external)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonExternal: {
|
||||
control: "boolean",
|
||||
description: "Whether the button opens an external URL",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonVariant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"],
|
||||
description: "Variant for the button. Must be 'custom' for button styling controls to work.",
|
||||
table: { category: "Button Styling (Only applicable when buttonVariant is 'custom')" },
|
||||
},
|
||||
onClick: {
|
||||
action: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
table: { category: "Events" },
|
||||
},
|
||||
...elementStylingArgTypes,
|
||||
...buttonStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "cta-1",
|
||||
inputId: "cta-input-1",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "cta-2",
|
||||
inputId: "cta-input-2",
|
||||
headline: "Ready to get started?",
|
||||
description: "Click the button below to begin your journey",
|
||||
buttonLabel: "Get Started",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ExternalButton: Story = {
|
||||
args: {
|
||||
elementId: "cta-3",
|
||||
inputId: "cta-input-3",
|
||||
headline: "Learn more about us",
|
||||
description: "Visit our website to learn more",
|
||||
buttonLabel: "Visit Website",
|
||||
buttonUrl: "https://example.com",
|
||||
buttonExternal: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "cta-4",
|
||||
inputId: "cta-input-4",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
required: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "cta-5",
|
||||
inputId: "cta-input-5",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
required: true,
|
||||
errorMessage: "Please click the button to continue",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "cta-6",
|
||||
inputId: "cta-input-6",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
disabled: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "cta-rtl",
|
||||
inputId: "cta-input-rtl",
|
||||
headline: "هل أنت مستعد للبدء؟",
|
||||
description: "انقر على الزر أدناه للبدء",
|
||||
buttonLabel: "ابدأ الآن",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<CTA
|
||||
elementId="cta-1"
|
||||
inputId="cta-input-1"
|
||||
headline="Ready to get started?"
|
||||
description="Click the button below to begin"
|
||||
buttonLabel="Get Started"
|
||||
onClick={() => {
|
||||
alert("clicked");
|
||||
}}
|
||||
/>
|
||||
<CTA
|
||||
elementId="cta-2"
|
||||
inputId="cta-input-2"
|
||||
headline="Learn more about us"
|
||||
description="Visit our website"
|
||||
buttonLabel="Visit Website"
|
||||
buttonUrl="https://example.com"
|
||||
buttonExternal
|
||||
onClick={() => {
|
||||
alert("clicked");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
96
packages/survey-ui/src/components/elements/cta.tsx
Normal file
96
packages/survey-ui/src/components/elements/cta.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { LinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/general/button";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
|
||||
/**
|
||||
* Props for the CTA (Call to Action) element component
|
||||
*/
|
||||
export interface CTAProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the CTA button */
|
||||
inputId: string;
|
||||
/** Label text for the CTA button */
|
||||
buttonLabel: string;
|
||||
/** URL to open when button is clicked (if external button) */
|
||||
buttonUrl?: string;
|
||||
/** Whether the button opens an external URL */
|
||||
buttonExternal?: boolean;
|
||||
/** Callback function called when button is clicked */
|
||||
onClick: () => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the button is disabled */
|
||||
disabled?: boolean;
|
||||
/** Variant for the button */
|
||||
buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
|
||||
}
|
||||
|
||||
function CTA({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
buttonLabel,
|
||||
buttonUrl,
|
||||
buttonExternal = false,
|
||||
onClick,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
buttonVariant = "default",
|
||||
}: CTAProps): React.JSX.Element {
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? "", buttonLabel],
|
||||
});
|
||||
|
||||
const handleButtonClick = (): void => {
|
||||
if (disabled) return;
|
||||
onClick();
|
||||
|
||||
if (buttonExternal && buttonUrl) {
|
||||
window.open(buttonUrl, "_blank")?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
|
||||
<div className="flex w-full justify-start">
|
||||
<Button
|
||||
id={inputId}
|
||||
type="button"
|
||||
onClick={handleButtonClick}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-2"
|
||||
variant={buttonVariant}>
|
||||
{buttonLabel}
|
||||
{buttonExternal ? <LinkIcon className="size-4" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CTA };
|
||||
313
packages/survey-ui/src/components/elements/date.stories.tsx
Normal file
313
packages/survey-ui/src/components/elements/date.stories.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { DateElement, type DateElementProps } from "./date";
|
||||
|
||||
type StoryProps = DateElementProps &
|
||||
Partial<BaseStylingOptions & Pick<InputLayoutStylingOptions, "inputBorderRadius">> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Date",
|
||||
component: DateElement,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete date element that combines headline, description, and a date input. Supports date range constraints, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Current date value in ISO format (YYYY-MM-DD)",
|
||||
table: { category: "State" },
|
||||
},
|
||||
minDate: {
|
||||
control: "text",
|
||||
description: "Minimum date allowed (ISO format: YYYY-MM-DD)",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
maxDate: {
|
||||
control: "text",
|
||||
description: "Maximum date allowed (ISO format: YYYY-MM-DD)",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
locale: {
|
||||
control: { type: "select" },
|
||||
options: [
|
||||
"en",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"ja",
|
||||
"pt",
|
||||
"pt-BR",
|
||||
"ro",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"nl",
|
||||
"ar",
|
||||
"it",
|
||||
"ru",
|
||||
"uz",
|
||||
"hi",
|
||||
],
|
||||
description: "Locale code for date formatting (survey language codes: 'en', 'de', 'ar', etc.)",
|
||||
table: { category: "Localization" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(DateElement),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select a date",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
inputBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "When would you like to schedule the appointment?",
|
||||
description: "Please select a date for your appointment",
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select your date of birth",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
value: "1990-01-15",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDateRange: Story = {
|
||||
args: {
|
||||
headline: "Select a date for your event",
|
||||
description: "Please choose a date between today and next year",
|
||||
minDate: new Date().toISOString().split("T")[0],
|
||||
maxDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select your date of birth",
|
||||
errorMessage: "Please select a valid date",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This date field is disabled",
|
||||
description: "You cannot change the date",
|
||||
value: "2024-01-15",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const PastDatesOnly: Story = {
|
||||
args: {
|
||||
headline: "When did you start your current job?",
|
||||
description: "Select a date in the past",
|
||||
maxDate: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const FutureDatesOnly: Story = {
|
||||
args: {
|
||||
headline: "When would you like to schedule the meeting?",
|
||||
description: "Select a date in the future",
|
||||
minDate: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو تاريخ ميلادك؟",
|
||||
description: "يرجى اختيار تاريخ",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithValue: Story = {
|
||||
args: {
|
||||
headline: "ما هو تاريخ ميلادك؟",
|
||||
description: "يرجى اختيار تاريخ",
|
||||
value: "1990-01-15",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<DateElement
|
||||
elementId="date-1"
|
||||
inputId="date-1-input"
|
||||
headline="What is your date of birth?"
|
||||
description="Please select your date of birth"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<DateElement
|
||||
elementId="date-2"
|
||||
inputId="date-2-input"
|
||||
headline="When would you like to schedule the appointment?"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLocale: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Date picker with locale-specific formatting",
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
export const LocaleExamples: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">English (en)</h3>
|
||||
<DateElement
|
||||
elementId="date-en"
|
||||
inputId="date-en-input"
|
||||
headline="What is your date of birth?"
|
||||
locale="en"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">German (de)</h3>
|
||||
<DateElement
|
||||
elementId="date-de"
|
||||
inputId="date-de-input"
|
||||
headline="Was ist Ihr Geburtsdatum?"
|
||||
locale="de"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">French (fr)</h3>
|
||||
<DateElement
|
||||
elementId="date-fr"
|
||||
inputId="date-fr-input"
|
||||
headline="Quelle est votre date de naissance ?"
|
||||
locale="fr"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Spanish (es)</h3>
|
||||
<DateElement
|
||||
elementId="date-es"
|
||||
inputId="date-es-input"
|
||||
headline="¿Cuál es su fecha de nacimiento?"
|
||||
locale="es"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Japanese (ja)</h3>
|
||||
<DateElement
|
||||
elementId="date-ja"
|
||||
inputId="date-ja-input"
|
||||
headline="生年月日を教えてください"
|
||||
locale="ja"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Arabic (ar)</h3>
|
||||
<DateElement
|
||||
elementId="date-ar"
|
||||
inputId="date-ar-input"
|
||||
headline="ما هو تاريخ ميلادك؟"
|
||||
locale="ar"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Russian (ru)</h3>
|
||||
<DateElement
|
||||
elementId="date-ru"
|
||||
inputId="date-ru-input"
|
||||
headline="Какова ваша дата рождения?"
|
||||
locale="ru"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Chinese Simplified (zh-Hans)</h3>
|
||||
<DateElement
|
||||
elementId="date-zh"
|
||||
inputId="date-zh-input"
|
||||
headline="您的出生日期是什么?"
|
||||
locale="zh-Hans"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
133
packages/survey-ui/src/components/elements/date.tsx
Normal file
133
packages/survey-ui/src/components/elements/date.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as React from "react";
|
||||
import { Calendar } from "@/components/general/calendar";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { getDateFnsLocale } from "@/lib/locale";
|
||||
|
||||
interface DateElementProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the date input */
|
||||
inputId: string;
|
||||
/** Current date value in ISO format (YYYY-MM-DD) */
|
||||
value?: string;
|
||||
/** Callback function called when the date value changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Minimum date allowed (ISO format: YYYY-MM-DD) */
|
||||
minDate?: string;
|
||||
/** Maximum date allowed (ISO format: YYYY-MM-DD) */
|
||||
maxDate?: string;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the date input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
function DateElement({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
minDate,
|
||||
maxDate,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
locale = "en-US",
|
||||
}: DateElementProps): React.JSX.Element {
|
||||
const [date, setDate] = React.useState<Date | undefined>(value ? new Date(value) : undefined);
|
||||
|
||||
// Convert Date to ISO string (YYYY-MM-DD) when date changes
|
||||
const handleDateSelect = (selectedDate: Date | undefined): void => {
|
||||
setDate(selectedDate);
|
||||
if (selectedDate) {
|
||||
// Convert to ISO format (YYYY-MM-DD)
|
||||
const isoString = selectedDate.toISOString().split("T")[0];
|
||||
onChange(isoString);
|
||||
} else {
|
||||
onChange("");
|
||||
}
|
||||
};
|
||||
|
||||
// Convert minDate/maxDate strings to Date objects
|
||||
const minDateObj = minDate ? new Date(minDate) : undefined;
|
||||
const maxDateObj = maxDate ? new Date(maxDate) : undefined;
|
||||
|
||||
// Create disabled function for date restrictions
|
||||
const isDateDisabled = React.useCallback(
|
||||
(dateToCheck: Date): boolean => {
|
||||
if (disabled) return true;
|
||||
if (minDateObj) {
|
||||
const minAtMidnight = new Date(minDateObj.getFullYear(), minDateObj.getMonth(), minDateObj.getDate());
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
if (checkAtMidnight < minAtMidnight) return true;
|
||||
}
|
||||
if (maxDateObj) {
|
||||
const maxAtMidnight = new Date(maxDateObj.getFullYear(), maxDateObj.getMonth(), maxDateObj.getDate());
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
if (checkAtMidnight > maxAtMidnight) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[disabled, minDateObj, maxDateObj]
|
||||
);
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? ""],
|
||||
});
|
||||
|
||||
// Get locale for date formatting
|
||||
const dateLocale = React.useMemo(() => {
|
||||
return locale ? getDateFnsLocale(locale) : undefined;
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Calendar - Always visible */}
|
||||
<div className="w-full">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
captionLayout="dropdown"
|
||||
disabled={isDateDisabled}
|
||||
onSelect={handleDateSelect}
|
||||
fromYear={minDateObj?.getFullYear() ?? 1900}
|
||||
toYear={maxDateObj?.getFullYear() ?? 2100}
|
||||
locale={dateLocale}
|
||||
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input w-full border"
|
||||
classNames={{
|
||||
root: "w-full",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DateElement };
|
||||
export type { DateElementProps };
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
pickArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { FileUpload, type FileUploadProps, type UploadedFile } from "./file-upload";
|
||||
|
||||
type StoryProps = FileUploadProps &
|
||||
Partial<BaseStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/FileUpload",
|
||||
component: FileUpload,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete file upload element that combines headline, description, and a file upload area with drag-and-drop support. Supports file type restrictions, size limits, multiple files, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Array of uploaded files",
|
||||
table: { category: "State" },
|
||||
},
|
||||
allowMultiple: {
|
||||
control: "boolean",
|
||||
description: "Whether multiple files are allowed",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
maxSizeInMB: {
|
||||
control: "number",
|
||||
description: "Maximum file size in MB",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
allowedFileExtensions: {
|
||||
control: "object",
|
||||
description: "Allowed file extensions (e.g., ['.pdf', '.jpg'])",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
...elementStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontSize",
|
||||
"inputFontWeight",
|
||||
"inputWidth",
|
||||
"inputHeight",
|
||||
"inputBorderRadius",
|
||||
"inputPaddingX",
|
||||
"inputPaddingY",
|
||||
]),
|
||||
},
|
||||
render: function Render(args: StoryProps) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<FileUpload
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Upload your file",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "Upload your resume",
|
||||
description: "Please upload your resume in PDF format",
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleFile: Story = {
|
||||
args: {
|
||||
headline: "Upload a single file",
|
||||
description: "Select one file to upload",
|
||||
allowMultiple: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleFiles: Story = {
|
||||
args: {
|
||||
headline: "Upload multiple files",
|
||||
description: "You can upload multiple files at once",
|
||||
allowMultiple: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFileTypeRestrictions: Story = {
|
||||
args: {
|
||||
headline: "Upload an image",
|
||||
description: "Please upload an image file",
|
||||
allowedFileExtensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSizeLimit: Story = {
|
||||
args: {
|
||||
headline: "Upload a document",
|
||||
description: "Maximum file size: 5MB",
|
||||
maxSizeInMB: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRestrictions: Story = {
|
||||
args: {
|
||||
headline: "Upload a PDF document",
|
||||
description: "PDF files only, maximum 10MB",
|
||||
allowedFileExtensions: [".pdf"],
|
||||
maxSizeInMB: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Upload required file",
|
||||
description: "Please upload a file",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithUploadedFiles: Story = {
|
||||
args: {
|
||||
headline: "Upload your files",
|
||||
description: "Files you've uploaded",
|
||||
allowMultiple: true,
|
||||
value: [
|
||||
{
|
||||
name: "document.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 500, // 500 KB
|
||||
},
|
||||
{
|
||||
name: "image.jpg",
|
||||
url: "data:image/jpeg;base64,...",
|
||||
size: 1024 * 1024 * 2, // 2 MB
|
||||
},
|
||||
] as UploadedFile[],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Upload your file",
|
||||
description: "Please upload a file",
|
||||
errorMessage: "Please upload at least one file",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This upload is disabled",
|
||||
description: "You cannot upload files",
|
||||
value: [
|
||||
{
|
||||
name: "existing-file.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 300,
|
||||
},
|
||||
] as UploadedFile[],
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "قم بتحميل ملفك",
|
||||
description: "يرجى اختيار ملف للتحميل",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithFiles: Story = {
|
||||
args: {
|
||||
headline: "قم بتحميل ملفاتك",
|
||||
description: "الملفات التي قمت بتحميلها",
|
||||
allowMultiple: true,
|
||||
value: [
|
||||
{
|
||||
name: "ملف.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 500,
|
||||
},
|
||||
] as UploadedFile[],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<FileUpload
|
||||
elementId="file-1"
|
||||
inputId="file-1-input"
|
||||
headline="Upload your resume"
|
||||
description="PDF format only"
|
||||
allowedFileExtensions={[".pdf"]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<FileUpload
|
||||
elementId="file-2"
|
||||
inputId="file-2-input"
|
||||
headline="Upload multiple images"
|
||||
description="You can upload multiple images"
|
||||
allowMultiple
|
||||
allowedFileExtensions={[".jpg", ".png", ".gif"]}
|
||||
maxSizeInMB={5}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
326
packages/survey-ui/src/components/elements/file-upload.tsx
Normal file
326
packages/survey-ui/src/components/elements/file-upload.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { Upload, UploadIcon, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Uploaded file information
|
||||
*/
|
||||
export interface UploadedFile {
|
||||
/** File name */
|
||||
name: string;
|
||||
/** File URL or data URL */
|
||||
url: string;
|
||||
/** File size in bytes */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface FileUploadProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the file input */
|
||||
inputId: string;
|
||||
/** Currently uploaded files */
|
||||
value?: UploadedFile[];
|
||||
/** Callback function called when files change */
|
||||
onChange: (files: UploadedFile[]) => void;
|
||||
/** Whether multiple files are allowed */
|
||||
allowMultiple?: boolean;
|
||||
/** Maximum file size in MB */
|
||||
maxSizeInMB?: number;
|
||||
/** Allowed file extensions (e.g., ['.pdf', '.jpg', '.png']) */
|
||||
allowedFileExtensions?: string[];
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the file input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
/** Alt text for the image */
|
||||
imageAltText?: string;
|
||||
/** Placeholder text for the file upload */
|
||||
placeholderText?: string;
|
||||
}
|
||||
|
||||
function FileUpload({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
value = [],
|
||||
onChange,
|
||||
allowMultiple = false,
|
||||
maxSizeInMB,
|
||||
allowedFileExtensions,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
imageAltText,
|
||||
placeholderText = "Click or drag to upload files",
|
||||
}: FileUploadProps): React.JSX.Element {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
|
||||
// Ensure value is always an array
|
||||
const uploadedFiles = Array.isArray(value) ? value : [];
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
// Check file extension
|
||||
if (allowedFileExtensions && allowedFileExtensions.length > 0) {
|
||||
const fileExtensionPart = file.name.split(".").pop()?.toLowerCase();
|
||||
if (fileExtensionPart) {
|
||||
const fileExtension = `.${fileExtensionPart}`;
|
||||
if (!allowedFileExtensions.includes(fileExtension)) {
|
||||
return `File type ${fileExtension} is not allowed. Allowed types: ${allowedFileExtensions.join(", ")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (maxSizeInMB) {
|
||||
const fileSizeInMB = file.size / (1024 * 1024);
|
||||
if (fileSizeInMB > maxSizeInMB) {
|
||||
return `File size exceeds the maximum allowed size of ${String(maxSizeInMB)}MB`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const processFiles = async (files: FileList | File[]): Promise<UploadedFile[]> => {
|
||||
const fileArray = Array.from(files);
|
||||
const processedFiles: UploadedFile[] = [];
|
||||
|
||||
for (const file of fileArray) {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
// In a real implementation, you might want to show this error
|
||||
// eslint-disable-next-line no-console -- Error logging needed for file validation
|
||||
console.error(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a data URL for preview (in real implementation, upload to server)
|
||||
const url = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
resolve(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
processedFiles.push({
|
||||
name: file.name,
|
||||
url,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
};
|
||||
|
||||
const handleFileSelection = async (files: FileList | File[]): Promise<void> => {
|
||||
if (disabled) return;
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
|
||||
// Validate file limits
|
||||
if (!allowMultiple && fileArray.length > 1) {
|
||||
// eslint-disable-next-line no-alert -- Alert needed for user feedback
|
||||
alert("Only one file can be uploaded at a time");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
const newFiles = await processFiles(fileArray);
|
||||
if (allowMultiple) {
|
||||
onChange([...uploadedFiles, ...newFiles]);
|
||||
} else {
|
||||
onChange(newFiles.slice(0, 1));
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console -- Error logging needed
|
||||
console.error("Error uploading files:", err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset input to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
if (!e.target.files || disabled) return;
|
||||
await handleFileSelection(e.target.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleFileSelection(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDeleteFile = (index: number, e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
const updatedFiles = [...uploadedFiles];
|
||||
updatedFiles.splice(index, 1);
|
||||
onChange(updatedFiles);
|
||||
};
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? ""],
|
||||
});
|
||||
|
||||
// Build accept attribute from allowed extensions
|
||||
const acceptAttribute = allowedFileExtensions
|
||||
?.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
|
||||
// Show uploader if uploading, or if multiple files allowed, or if no files uploaded yet
|
||||
const showUploader = isUploading || allowMultiple || uploadedFiles.length === 0;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
imageAltText={imageAltText}
|
||||
/>
|
||||
|
||||
{/* File Input */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
|
||||
{/* Dashed border container */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-input px-input-x py-input-y rounded-input relative flex flex-col items-center justify-center border-2 border-dashed transition-colors",
|
||||
errorMessage ? "border-destructive" : "border-input-border bg-input-bg hover:bg-input-hover-bg",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}>
|
||||
{/* Uploaded files */}
|
||||
{uploadedFiles.length > 0 ? (
|
||||
<div className="flex w-full flex-col gap-2 p-2">
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"border-input-border bg-input-bg text-input-text rounded-input relative m-1 rounded-md border"
|
||||
)}>
|
||||
{/* Delete button */}
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
handleDeleteFile(index, e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-5 w-5 cursor-pointer items-center justify-center rounded-md",
|
||||
"bg-background hover:bg-accent",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label={`Delete ${file.name}`}>
|
||||
<X className="text-foreground h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* File icon and name */}
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<UploadIcon />
|
||||
<p
|
||||
className="mt-1 w-full overflow-hidden overflow-ellipsis whitespace-nowrap px-2 text-center text-sm text-[var(--foreground)]"
|
||||
title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Upload area */}
|
||||
<div className="w-full">
|
||||
{isUploading ? (
|
||||
<div className="flex animate-pulse items-center justify-center rounded-lg py-4">
|
||||
<p className="text-muted-foreground text-sm font-medium">Uploading...</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
className={cn("block w-full", disabled && "cursor-not-allowed", !showUploader && "hidden")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center justify-center py-6",
|
||||
"hover:cursor-pointer",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Upload files by clicking or dragging them here">
|
||||
<Upload className="text-input-text h-6" aria-hidden="true" />
|
||||
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
|
||||
<span className="text-input-text font-input-weight text-input m-2" id={`${inputId}-label`}>
|
||||
{placeholderText}
|
||||
</span>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id={inputId}
|
||||
className="hidden"
|
||||
multiple={allowMultiple}
|
||||
accept={acceptAttribute}
|
||||
onChange={(e) => {
|
||||
void handleFileChange(e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
dir={detectedDir}
|
||||
aria-label="File upload"
|
||||
aria-describedby={`${inputId}-label`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FileUpload };
|
||||
export type { FileUploadProps };
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
} from "../../lib/story-helpers";
|
||||
import { FormField, type FormFieldConfig, type FormFieldProps } from "./form-field";
|
||||
|
||||
type StoryProps = FormFieldProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & InputLayoutStylingOptions & { inputShadow: string }> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/FormField",
|
||||
component: FormField,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A flexible form field element that can display multiple input fields with different configurations. Replaces Contact Info and Address elements.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
fields: {
|
||||
control: "object",
|
||||
description: "Array of form field configurations",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Current values as a record mapping field IDs to their values",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(FormField),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
// Contact Info fields preset
|
||||
const contactInfoFields: FormFieldConfig[] = [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: true, show: true },
|
||||
{ id: "phone", label: "Phone", placeholder: "Phone", type: "tel", required: true, show: true },
|
||||
{ id: "company", label: "Company", placeholder: "Company", required: true, show: true },
|
||||
];
|
||||
|
||||
// Address fields preset
|
||||
const addressFields: FormFieldConfig[] = [
|
||||
{ id: "addressLine1", label: "Address Line 1", placeholder: "Address Line 1", required: true, show: true },
|
||||
{ id: "addressLine2", label: "Address Line 2", placeholder: "Address Line 2", required: true, show: true },
|
||||
{ id: "city", label: "City", placeholder: "City", required: true, show: true },
|
||||
{ id: "state", label: "State", placeholder: "State", required: true, show: true },
|
||||
{ id: "zip", label: "Zip", placeholder: "Zip", required: true, show: true },
|
||||
{ id: "country", label: "Country", placeholder: "Country", required: true, show: true },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "form-field-1",
|
||||
headline: "Please provide your contact information",
|
||||
description: "We'll use this to contact you",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
argTypes: {
|
||||
elementHeadlineFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
labelFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelColor: {
|
||||
control: "color",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
inputWidth: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputHeight: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPaddingX: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPaddingY: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputShadow: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
brandColor: {
|
||||
control: "color",
|
||||
table: { category: "Survey Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "form-field-1",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "form-field-2",
|
||||
headline: "Please provide your contact information",
|
||||
description: "We'll use this to contact you about your inquiry",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const ContactInfo: Story = {
|
||||
args: {
|
||||
elementId: "form-field-contact",
|
||||
headline: "Contact Information",
|
||||
description: "Please provide your contact details",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const Address: Story = {
|
||||
args: {
|
||||
elementId: "form-field-address",
|
||||
headline: "Shipping Address",
|
||||
description: "Please provide your shipping address",
|
||||
fields: addressFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "form-field-3",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValues: Story = {
|
||||
args: {
|
||||
elementId: "form-field-4",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
value: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john.doe@example.com",
|
||||
phone: "+1234567890",
|
||||
company: "Acme Inc.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "form-field-5",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
required: true,
|
||||
errorMessage: "Please fill in all required fields",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "form-field-6",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
disabled: true,
|
||||
value: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PartialFields: Story = {
|
||||
args: {
|
||||
elementId: "form-field-7",
|
||||
headline: "Basic Information",
|
||||
fields: [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true },
|
||||
{
|
||||
id: "phone",
|
||||
label: "Phone",
|
||||
placeholder: "Phone (optional)",
|
||||
type: "tel",
|
||||
required: false,
|
||||
show: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const OptionalFields: Story = {
|
||||
args: {
|
||||
elementId: "form-field-8",
|
||||
headline: "Optional Information",
|
||||
fields: [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: false, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: false, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "form-field-rtl",
|
||||
headline: "يرجى تقديم معلومات الاتصال الخاصة بك",
|
||||
description: "سنستخدم هذا للاتصال بك",
|
||||
fields: [
|
||||
{ id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true },
|
||||
{ id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true },
|
||||
{
|
||||
id: "email",
|
||||
label: "البريد الإلكتروني",
|
||||
placeholder: "البريد الإلكتروني",
|
||||
type: "email",
|
||||
required: true,
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithValues: Story = {
|
||||
args: {
|
||||
elementId: "form-field-rtl-values",
|
||||
headline: "يرجى تقديم معلومات الاتصال الخاصة بك",
|
||||
fields: [
|
||||
{ id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true },
|
||||
{ id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true },
|
||||
{
|
||||
id: "email",
|
||||
label: "البريد الإلكتروني",
|
||||
placeholder: "البريد الإلكتروني",
|
||||
type: "email",
|
||||
required: true,
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
value: {
|
||||
firstName: "أحمد",
|
||||
lastName: "محمد",
|
||||
email: "ahmed@example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<FormField
|
||||
elementId="form-field-1"
|
||||
headline="Contact Information"
|
||||
description="Please provide your contact details"
|
||||
fields={contactInfoFields}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<FormField
|
||||
elementId="form-field-2"
|
||||
headline="Shipping Address"
|
||||
description="Where should we ship your order?"
|
||||
fields={addressFields}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
153
packages/survey-ui/src/components/elements/form-field.tsx
Normal file
153
packages/survey-ui/src/components/elements/form-field.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
|
||||
/**
|
||||
* Form field configuration
|
||||
*/
|
||||
export interface FormFieldConfig {
|
||||
/** Unique identifier for the field */
|
||||
id: string;
|
||||
/** Label text for the field */
|
||||
label: string;
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string;
|
||||
/** Input type (text, email, tel, number, url, etc.) */
|
||||
type?: "text" | "email" | "tel" | "number" | "url";
|
||||
/** Whether this field is required */
|
||||
required?: boolean;
|
||||
/** Whether this field should be shown */
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Array of form field configurations */
|
||||
fields: FormFieldConfig[];
|
||||
/** Current values as a record mapping field IDs to their values */
|
||||
value?: Record<string, string>;
|
||||
/** Callback function called when any field value changes */
|
||||
onChange: (value: Record<string, string>) => void;
|
||||
/** Whether the entire form is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
fields,
|
||||
value = {},
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: FormFieldProps): React.JSX.Element {
|
||||
// Ensure value is always an object
|
||||
const currentValues = React.useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- value can be undefined
|
||||
return value ?? {};
|
||||
}, [value]);
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [
|
||||
headline,
|
||||
description ?? "",
|
||||
...fields.map((field) => field.label).filter(Boolean),
|
||||
...fields.map((field) => field.placeholder ?? "").filter(Boolean),
|
||||
],
|
||||
});
|
||||
|
||||
// Determine if a field is required
|
||||
const isFieldRequired = (field: FormFieldConfig): boolean => {
|
||||
if (field.required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If all fields are optional and the form is required, then fields should be required
|
||||
const visibleFields = fields.filter((f) => f.show !== false);
|
||||
const allOptional = visibleFields.every((f) => !f.required);
|
||||
if (allOptional && required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Handle field value change
|
||||
const handleFieldChange = (fieldId: string, fieldValue: string): void => {
|
||||
onChange({
|
||||
...currentValues,
|
||||
[fieldId]: fieldValue,
|
||||
});
|
||||
};
|
||||
|
||||
// Get visible fields
|
||||
const visibleFields = fields.filter((field) => field.show !== false);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} />
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative space-y-3">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
{visibleFields.map((field) => {
|
||||
const fieldRequired = isFieldRequired(field);
|
||||
const fieldValue = currentValues[field.id] ?? "";
|
||||
const fieldInputId = `${elementId}-${field.id}`;
|
||||
|
||||
// Determine input type
|
||||
let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text";
|
||||
if (field.id === "email" && !field.type) {
|
||||
inputType = "email";
|
||||
} else if (field.id === "phone" && !field.type) {
|
||||
inputType = "tel";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={fieldInputId} variant="default">
|
||||
{fieldRequired ? `${field.label}*` : field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldInputId}
|
||||
type={inputType}
|
||||
placeholder={field.placeholder}
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
handleFieldChange(field.id, e.target.value);
|
||||
}}
|
||||
required={fieldRequired}
|
||||
disabled={disabled}
|
||||
dir={!fieldValue ? detectedDir : "auto"}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormField };
|
||||
export type { FormFieldProps };
|
||||
305
packages/survey-ui/src/components/elements/matrix.stories.tsx
Normal file
305
packages/survey-ui/src/components/elements/matrix.stories.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Matrix, type MatrixOption, type MatrixProps } from "./matrix";
|
||||
|
||||
type StoryProps = MatrixProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Matrix",
|
||||
component: Matrix,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete matrix element that combines headline, description, and a table with rows and columns. Each row can have one selected column value. Supports validation and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
rows: {
|
||||
control: "object",
|
||||
description: "Array of row options (left side)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
columns: {
|
||||
control: "object",
|
||||
description: "Array of column options (top header)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Record mapping row ID to column ID",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Matrix),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
const defaultRows: MatrixOption[] = [
|
||||
{ id: "row-1", label: "Row 1" },
|
||||
{ id: "row-2", label: "Row 2" },
|
||||
{ id: "row-3", label: "Row 3" },
|
||||
];
|
||||
|
||||
const defaultColumns: MatrixOption[] = [
|
||||
{ id: "col-1", label: "Column 1" },
|
||||
{ id: "col-2", label: "Column 2" },
|
||||
{ id: "col-3", label: "Column 3" },
|
||||
{ id: "col-4", label: "Column 4" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Rate each item",
|
||||
description: "Select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, ["inputBgColor", "inputBorderColor"]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps & Record<string, unknown>>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "matrix-default",
|
||||
inputId: "matrix-default-input",
|
||||
headline: "Rate each item",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "matrix-with-description",
|
||||
inputId: "matrix-with-description-input",
|
||||
headline: "How satisfied are you with each feature?",
|
||||
description: "Please rate each feature on a scale from 1 to 5",
|
||||
rows: [
|
||||
{ id: "feature-1", label: "Feature 1" },
|
||||
{ id: "feature-2", label: "Feature 2" },
|
||||
{ id: "feature-3", label: "Feature 3" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "matrix-required",
|
||||
inputId: "matrix-required-input",
|
||||
headline: "Rate each item",
|
||||
description: "Please select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelections: Story = {
|
||||
args: {
|
||||
elementId: "matrix-selections",
|
||||
inputId: "matrix-selections-input",
|
||||
headline: "Rate each item",
|
||||
description: "Select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
value: {
|
||||
"row-1": "col-2",
|
||||
"row-2": "col-3",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "matrix-error",
|
||||
inputId: "matrix-error-input",
|
||||
headline: "Rate each item",
|
||||
description: "Please select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
errorMessage: "Please complete all rows",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "matrix-disabled",
|
||||
inputId: "matrix-disabled-input",
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
value: {
|
||||
"row-1": "col-2",
|
||||
"row-2": "col-3",
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RatingScale: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rating-scale",
|
||||
inputId: "matrix-rating-scale-input",
|
||||
headline: "Rate your experience",
|
||||
description: "How would you rate each aspect?",
|
||||
rows: [
|
||||
{ id: "quality", label: "Quality" },
|
||||
{ id: "service", label: "Service" },
|
||||
{ id: "value", label: "Value for Money" },
|
||||
{ id: "support", label: "Customer Support" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "poor", label: "Poor" },
|
||||
{ id: "fair", label: "Fair" },
|
||||
{ id: "good", label: "Good" },
|
||||
{ id: "very-good", label: "Very Good" },
|
||||
{ id: "excellent", label: "Excellent" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NumericScale: Story = {
|
||||
args: {
|
||||
elementId: "matrix-numeric-scale",
|
||||
inputId: "matrix-numeric-scale-input",
|
||||
headline: "Rate from 0 to 10",
|
||||
description: "Select a number for each item",
|
||||
rows: [
|
||||
{ id: "item-1", label: "Item 1" },
|
||||
{ id: "item-2", label: "Item 2" },
|
||||
{ id: "item-3", label: "Item 3" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "0", label: "0" },
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
{ id: "6", label: "6" },
|
||||
{ id: "7", label: "7" },
|
||||
{ id: "8", label: "8" },
|
||||
{ id: "9", label: "9" },
|
||||
{ id: "10", label: "10" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rtl",
|
||||
inputId: "matrix-rtl-input",
|
||||
headline: "قيم كل عنصر",
|
||||
description: "اختر قيمة لكل صف",
|
||||
rows: [
|
||||
{ id: "row-1", label: "الصف الأول" },
|
||||
{ id: "row-2", label: "الصف الثاني" },
|
||||
{ id: "row-3", label: "الصف الثالث" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "col-1", label: "عمود 1" },
|
||||
{ id: "col-2", label: "عمود 2" },
|
||||
{ id: "col-3", label: "عمود 3" },
|
||||
{ id: "col-4", label: "عمود 4" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelections: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rtl-selections",
|
||||
inputId: "matrix-rtl-selections-input",
|
||||
headline: "قيم كل عنصر",
|
||||
description: "يرجى اختيار قيمة لكل صف",
|
||||
rows: [
|
||||
{ id: "quality", label: "الجودة" },
|
||||
{ id: "service", label: "الخدمة" },
|
||||
{ id: "value", label: "القيمة" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "poor", label: "ضعيف" },
|
||||
{ id: "fair", label: "مقبول" },
|
||||
{ id: "good", label: "جيد" },
|
||||
{ id: "very-good", label: "جيد جداً" },
|
||||
{ id: "excellent", label: "ممتاز" },
|
||||
],
|
||||
value: {
|
||||
quality: "good",
|
||||
service: "very-good",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Matrix
|
||||
elementId="matrix-1"
|
||||
inputId="matrix-1-input"
|
||||
headline="Rate each item"
|
||||
description="Select a value for each row"
|
||||
rows={defaultRows}
|
||||
columns={defaultColumns}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Matrix
|
||||
elementId="matrix-2"
|
||||
inputId="matrix-2-input"
|
||||
headline="How satisfied are you?"
|
||||
rows={[
|
||||
{ id: "feature-1", label: "Feature 1" },
|
||||
{ id: "feature-2", label: "Feature 2" },
|
||||
]}
|
||||
columns={[
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
]}
|
||||
value={{
|
||||
"feature-1": "4",
|
||||
"feature-2": "5",
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
173
packages/survey-ui/src/components/elements/matrix.tsx
Normal file
173
packages/survey-ui/src/components/elements/matrix.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { RadioGroupItem } from "@/components/general/radio-group";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Option for matrix element rows and columns
|
||||
*/
|
||||
export interface MatrixOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MatrixProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the matrix group */
|
||||
inputId: string;
|
||||
/** Array of row options (left side) */
|
||||
rows: MatrixOption[];
|
||||
/** Array of column options (top header) */
|
||||
columns: MatrixOption[];
|
||||
/** Currently selected values: Record mapping row ID to column ID */
|
||||
value?: Record<string, string>;
|
||||
/** Callback function called when selection changes */
|
||||
onChange: (value: Record<string, string>) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Matrix({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
rows,
|
||||
columns,
|
||||
value = {},
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: MatrixProps): React.JSX.Element {
|
||||
// Ensure value is always an object (value already has default of {})
|
||||
const selectedValues = value;
|
||||
|
||||
// Check which rows have errors (no selection when required)
|
||||
const hasError = Boolean(errorMessage);
|
||||
const rowsWithErrors = hasError && required ? rows.filter((row) => !selectedValues[row.id]) : [];
|
||||
|
||||
const handleRowChange = (rowId: string, columnId: string): void => {
|
||||
// Toggle: if same column is selected, deselect it
|
||||
if (selectedValues[rowId] === columnId) {
|
||||
// Create new object without the rowId property
|
||||
const { [rowId]: _, ...rest } = selectedValues;
|
||||
onChange(rest);
|
||||
} else {
|
||||
onChange({ ...selectedValues, [rowId]: columnId });
|
||||
}
|
||||
};
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [
|
||||
headline,
|
||||
description ?? "",
|
||||
...rows.map((row) => row.label),
|
||||
...columns.map((col) => col.label),
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
|
||||
{/* Table container with overflow for mobile */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
{/* Column headers */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 text-left" />
|
||||
{columns.map((column) => (
|
||||
<th key={column.id} className="p-2 text-center font-normal">
|
||||
<Label className="justify-center">{column.label}</Label>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
{/* Rows */}
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const rowGroupId = `${inputId}-row-${row.id}`;
|
||||
const selectedColumnId = selectedValues[row.id];
|
||||
const rowHasError = rowsWithErrors.includes(row);
|
||||
const baseBgColor = index % 2 === 0 ? "bg-input-bg" : "bg-transparent";
|
||||
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
key={row.id}
|
||||
asChild
|
||||
value={selectedColumnId}
|
||||
onValueChange={(newColumnId) => {
|
||||
handleRowChange(row.id, newColumnId);
|
||||
}}
|
||||
disabled={disabled}
|
||||
dir={detectedDir}
|
||||
aria-invalid={Boolean(errorMessage)}>
|
||||
<tr className={cn("relative", baseBgColor, rowHasError ? "bg-destructive-muted" : "")}>
|
||||
{/* Row label */}
|
||||
<td className={cn("p-2 align-middle", !rowHasError && "rounded-l-input")}>
|
||||
<div className="flex flex-col gap-0 leading-none">
|
||||
<Label>{row.label}</Label>
|
||||
{rowHasError ? (
|
||||
<span className="text-destructive text-xs font-normal">Select one option</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
{/* Column options for this row */}
|
||||
{columns.map((column, colIndex) => {
|
||||
const cellId = `${rowGroupId}-${column.id}`;
|
||||
const isLastColumn = colIndex === columns.length - 1;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.id}
|
||||
className={cn(
|
||||
"p-2 text-center align-middle",
|
||||
isLastColumn && !rowHasError && "rounded-r-input"
|
||||
)}>
|
||||
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
|
||||
<RadioGroupItem value={column.id} id={cellId} disabled={disabled} />
|
||||
</Label>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</RadioGroupPrimitive.Root>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Matrix };
|
||||
export type { MatrixProps };
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type CheckboxInputStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
type OptionStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
optionStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { MultiSelect, type MultiSelectOption, type MultiSelectProps } from "./multi-select";
|
||||
|
||||
type StoryProps = MultiSelectProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & OptionStylingOptions & CheckboxInputStylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/MultiSelect",
|
||||
component: MultiSelect,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete multi-select element that combines headline, description, and checkbox options. Supports multiple selections, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of options to choose from",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Array of selected option IDs",
|
||||
table: { category: "State" },
|
||||
},
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["list", "dropdown"],
|
||||
description: "Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for dropdown button when no options are selected",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: function Render(args: StoryProps) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
const [otherValue, setOtherValue] = useState(args.otherValue);
|
||||
const handleOtherValueChange = (v: string) => {
|
||||
setOtherValue(v);
|
||||
args.onOtherValueChange?.(v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={handleOtherValueChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
const defaultOptions: MultiSelectOption[] = [
|
||||
{ id: "option-1", label: "Option 1" },
|
||||
{ id: "option-2", label: "Option 2" },
|
||||
{ id: "option-3", label: "Option 3" },
|
||||
{ id: "option-4", label: "Option 4" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...optionStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps & Record<string, unknown>>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "What programming languages do you know?",
|
||||
description: "Select all programming languages you're familiar with",
|
||||
options: [
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
{ id: "java", label: "Java" },
|
||||
{ id: "go", label: "Go" },
|
||||
{ id: "rust", label: "Rust" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Select your interests",
|
||||
description: "Please select at least one option",
|
||||
options: [
|
||||
{ id: "tech", label: "Technology" },
|
||||
{ id: "design", label: "Design" },
|
||||
{ id: "marketing", label: "Marketing" },
|
||||
{ id: "sales", label: "Sales" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelections: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
value: ["option-1", "option-3"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Select your preferences",
|
||||
description: "Please select at least one option",
|
||||
options: [
|
||||
{ id: "email", label: "Email notifications" },
|
||||
{ id: "sms", label: "SMS notifications" },
|
||||
{ id: "push", label: "Push notifications" },
|
||||
],
|
||||
errorMessage: "Please select at least one option",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
options: defaultOptions,
|
||||
value: ["option-2"],
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
headline: "Select all that apply",
|
||||
description: "Choose as many as you like",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
{ id: "3", label: "Option 3" },
|
||||
{ id: "4", label: "Option 4" },
|
||||
{ id: "5", label: "Option 5" },
|
||||
{ id: "6", label: "Option 6" },
|
||||
{ id: "7", label: "Option 7" },
|
||||
{ id: "8", label: "Option 8" },
|
||||
{ id: "9", label: "Option 9" },
|
||||
{ id: "10", label: "Option 10" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هي الميزات التي تستخدمها؟",
|
||||
description: "اختر كل ما ينطبق",
|
||||
options: [
|
||||
{ id: "opt-1", label: "الخيار الأول" },
|
||||
{ id: "opt-2", label: "الخيار الثاني" },
|
||||
{ id: "opt-3", label: "الخيار الثالث" },
|
||||
{ id: "opt-4", label: "الخيار الرابع" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelections: Story = {
|
||||
args: {
|
||||
headline: "ما هي اهتماماتك؟",
|
||||
description: "يرجى اختيار جميع الخيارات المناسبة",
|
||||
options: [
|
||||
{ id: "tech", label: "التكنولوجيا" },
|
||||
{ id: "design", label: "التصميم" },
|
||||
{ id: "marketing", label: "التسويق" },
|
||||
{ id: "sales", label: "المبيعات" },
|
||||
],
|
||||
value: ["tech", "design"],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<MultiSelect
|
||||
elementId="features"
|
||||
inputId="features-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<MultiSelect
|
||||
elementId="languages"
|
||||
inputId="languages-input"
|
||||
headline="What programming languages do you know?"
|
||||
options={[
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
]}
|
||||
value={["js", "ts"]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Dropdown: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
variant: "dropdown",
|
||||
placeholder: "Select options...",
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithSelections: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
value: ["option-1", "option-3"],
|
||||
variant: "dropdown",
|
||||
placeholder: "Select options...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>([]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-other"
|
||||
inputId="multi-select-other-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOptionSelected: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>(["option-1", "other"]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("Custom feature");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-other-selected"
|
||||
inputId="multi-select-other-selected-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>([]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-dropdown-other"
|
||||
inputId="multi-select-dropdown-other-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variant="dropdown"
|
||||
placeholder="Select options..."
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
272
packages/survey-ui/src/components/elements/multi-select.tsx
Normal file
272
packages/survey-ui/src/components/elements/multi-select.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/general/button";
|
||||
import { Checkbox } from "@/components/general/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/general/dropdown-menu";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Option for multi-select element
|
||||
*/
|
||||
export interface MultiSelectOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the multi-select group */
|
||||
inputId: string;
|
||||
/** Array of options to choose from */
|
||||
options: MultiSelectOption[];
|
||||
/** Currently selected option IDs */
|
||||
value?: string[];
|
||||
/** Callback function called when selection changes */
|
||||
onChange: (value: string[]) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display below the options */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
/** Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu */
|
||||
variant?: "list" | "dropdown";
|
||||
/** Placeholder text for dropdown button when no options are selected */
|
||||
placeholder?: string;
|
||||
/** ID for the 'other' option that allows custom input */
|
||||
otherOptionId?: string;
|
||||
/** Label for the 'other' option */
|
||||
otherOptionLabel?: string;
|
||||
/** Placeholder text for the 'other' input field */
|
||||
otherOptionPlaceholder?: string;
|
||||
/** Custom value entered in the 'other' input field */
|
||||
otherValue?: string;
|
||||
/** Callback when the 'other' input value changes */
|
||||
onOtherValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function MultiSelect({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
variant = "list",
|
||||
placeholder = "Select options...",
|
||||
otherOptionId,
|
||||
otherOptionLabel = "Other",
|
||||
otherOptionPlaceholder = "Please specify",
|
||||
otherValue = "",
|
||||
onOtherValueChange,
|
||||
}: MultiSelectProps): React.JSX.Element {
|
||||
// Ensure value is always an array
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
const hasOtherOption = Boolean(otherOptionId);
|
||||
const isOtherSelected = Boolean(hasOtherOption && otherOptionId && selectedValues.includes(otherOptionId));
|
||||
|
||||
const handleOptionChange = (optionId: string, checked: boolean): void => {
|
||||
if (checked) {
|
||||
onChange([...selectedValues, optionId]);
|
||||
} else {
|
||||
onChange(selectedValues.filter((id) => id !== optionId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtherInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
onOtherValueChange?.(e.target.value);
|
||||
};
|
||||
|
||||
// Shared className for option containers
|
||||
const getOptionContainerClassName = (isSelected: boolean): string =>
|
||||
cn(
|
||||
"relative flex cursor-pointer flex-col border transition-colors outline-none",
|
||||
"rounded-option px-option-x py-option-y",
|
||||
isSelected ? "bg-option-selected-bg border-brand" : "bg-option-bg border-option-border",
|
||||
"focus-within:border-brand focus-within:bg-option-selected-bg",
|
||||
"hover:bg-option-hover-bg",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
);
|
||||
|
||||
// Shared className for option labels
|
||||
const optionLabelClassName = "font-option font-option-weight text-option text-option-label";
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [
|
||||
headline,
|
||||
description ?? "",
|
||||
...options.map((opt) => opt.label),
|
||||
...(hasOtherOption ? [otherOptionLabel] : []),
|
||||
],
|
||||
});
|
||||
|
||||
// Get selected option labels for dropdown display
|
||||
const selectedLabels = options.filter((opt) => selectedValues.includes(opt.id)).map((opt) => opt.label);
|
||||
|
||||
let displayText = placeholder;
|
||||
if (selectedLabels.length > 0) {
|
||||
displayText =
|
||||
selectedLabels.length === 1 ? selectedLabels[0] : `${String(selectedLabels.length)} selected`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative space-y-3">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
|
||||
{variant === "dropdown" ? (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="rounded-input w-full justify-between"
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
aria-label={headline}>
|
||||
<span className="truncate">{displayText}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]" align="start">
|
||||
{options.map((option) => {
|
||||
const isChecked = selectedValues.includes(option.id);
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={option.id}
|
||||
id={optionId}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
handleOptionChange(option.id, checked);
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{option.label}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<DropdownMenuCheckboxItem
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
checked={isOtherSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
handleOptionChange(otherOptionId, checked);
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{otherOptionLabel}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isOtherSelected ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={otherValue}
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
dir={detectedDir}
|
||||
className="w-full"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- Auto-focus is intentional for better UX when "other" option is selected
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2" role="group" aria-label={headline}>
|
||||
{options.map((option) => {
|
||||
const isChecked = selectedValues.includes(option.id);
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
className={cn(getOptionContainerClassName(isChecked), isChecked && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<Checkbox
|
||||
id={optionId}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
handleOptionChange(option.id, checked === true);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
/>
|
||||
<span className={cn("ml-3 mr-3", optionLabelClassName)}>{option.label}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={`${inputId}-${otherOptionId}`}
|
||||
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
|
||||
<span className="flex items-center">
|
||||
<Checkbox
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
checked={isOtherSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
handleOptionChange(otherOptionId, checked === true);
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
/>
|
||||
<span className={cn("ml-3 mr-3 grow", optionLabelClassName)}>{otherOptionLabel}</span>
|
||||
</span>
|
||||
{isOtherSelected ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={otherValue}
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
dir={detectedDir}
|
||||
className="mt-2 w-full"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- Auto-focus is intentional for better UX when "other" option is selected
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MultiSelect };
|
||||
export type { MultiSelectProps };
|
||||
242
packages/survey-ui/src/components/elements/nps.stories.tsx
Normal file
242
packages/survey-ui/src/components/elements/nps.stories.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { NPS, type NPSProps } from "./nps";
|
||||
|
||||
type StoryProps = NPSProps & Partial<BaseStylingOptions & LabelStylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/NPS",
|
||||
component: NPS,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A Net Promoter Score (NPS) element. Users can select a rating from 0 to 10 to indicate how likely they are to recommend something.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
value: {
|
||||
control: { type: "number", min: 0, max: 10 },
|
||||
description: "Currently selected NPS value (0-10)",
|
||||
table: { category: "State" },
|
||||
},
|
||||
lowerLabel: {
|
||||
control: "text",
|
||||
description: "Label for the lower end of the scale",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
upperLabel: {
|
||||
control: "text",
|
||||
description: "Label for the upper end of the scale",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
colorCoding: {
|
||||
control: "boolean",
|
||||
description: "Whether color coding is enabled",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(NPS),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "nps-1",
|
||||
inputId: "nps-input-1",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
description: "Please rate from 0 to 10",
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontWeight",
|
||||
"inputBorderRadius",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps & Record<string, unknown>>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "nps-1",
|
||||
inputId: "nps-input-1",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "nps-2",
|
||||
inputId: "nps-input-2",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
description: "Please rate from 0 to 10, where 0 is not at all likely and 10 is extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabels: Story = {
|
||||
args: {
|
||||
elementId: "nps-labels",
|
||||
inputId: "nps-input-labels",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
elementId: "nps-selection",
|
||||
inputId: "nps-input-selection",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 9,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "nps-required",
|
||||
inputId: "nps-input-required",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "nps-error",
|
||||
inputId: "nps-input-error",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
required: true,
|
||||
errorMessage: "Please select a rating",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "nps-disabled",
|
||||
inputId: "nps-input-disabled",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 8,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorCoding: Story = {
|
||||
args: {
|
||||
elementId: "nps-color",
|
||||
inputId: "nps-input-color",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
colorCoding: true,
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const Promoter: Story = {
|
||||
args: {
|
||||
elementId: "nps-promoter",
|
||||
inputId: "nps-input-promoter",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 9,
|
||||
colorCoding: true,
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const Passive: Story = {
|
||||
args: {
|
||||
elementId: "nps-passive",
|
||||
inputId: "nps-input-passive",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 7,
|
||||
colorCoding: true,
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const Detractor: Story = {
|
||||
args: {
|
||||
elementId: "nps-detractor",
|
||||
inputId: "nps-input-detractor",
|
||||
headline: "How likely are you to recommend us to a friend or colleague?",
|
||||
value: 5,
|
||||
colorCoding: true,
|
||||
lowerLabel: "Not at all likely",
|
||||
upperLabel: "Extremely likely",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "nps-rtl",
|
||||
inputId: "nps-input-rtl",
|
||||
headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟",
|
||||
description: "يرجى التقييم من 0 إلى 10",
|
||||
lowerLabel: "غير محتمل على الإطلاق",
|
||||
upperLabel: "محتمل للغاية",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelection: Story = {
|
||||
args: {
|
||||
elementId: "nps-rtl-selection",
|
||||
inputId: "nps-input-rtl-selection",
|
||||
headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟",
|
||||
value: 8,
|
||||
lowerLabel: "غير محتمل على الإطلاق",
|
||||
upperLabel: "محتمل للغاية",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<NPS
|
||||
elementId="nps-1"
|
||||
inputId="nps-input-1"
|
||||
headline="How likely are you to recommend our product?"
|
||||
lowerLabel="Not at all likely"
|
||||
upperLabel="Extremely likely"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<NPS
|
||||
elementId="nps-2"
|
||||
inputId="nps-input-2"
|
||||
headline="How likely are you to recommend our service?"
|
||||
description="Please rate from 0 to 10"
|
||||
value={9}
|
||||
colorCoding
|
||||
lowerLabel="Not at all likely"
|
||||
upperLabel="Extremely likely"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
198
packages/survey-ui/src/components/elements/nps.tsx
Normal file
198
packages/survey-ui/src/components/elements/nps.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NPSProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the NPS group */
|
||||
inputId: string;
|
||||
/** Currently selected NPS value (0 to 10) */
|
||||
value?: number;
|
||||
/** Callback function called when NPS value changes */
|
||||
onChange: (value: number) => void;
|
||||
/** Optional label for the lower end of the scale */
|
||||
lowerLabel?: string;
|
||||
/** Optional label for the upper end of the scale */
|
||||
upperLabel?: string;
|
||||
/** Whether color coding is enabled */
|
||||
colorCoding?: boolean;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function NPS({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
value,
|
||||
onChange,
|
||||
lowerLabel,
|
||||
upperLabel,
|
||||
colorCoding = false,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: NPSProps): React.JSX.Element {
|
||||
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
||||
|
||||
// Ensure value is within valid range (0-10)
|
||||
const currentValue = value !== undefined && value >= 0 && value <= 10 ? value : undefined;
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? "", lowerLabel ?? "", upperLabel ?? ""],
|
||||
});
|
||||
|
||||
// Handle NPS selection
|
||||
const handleSelect = (npsValue: number): void => {
|
||||
if (!disabled) {
|
||||
onChange(npsValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (npsValue: number) => (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect(npsValue);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
const direction = e.key === "ArrowLeft" ? -1 : 1;
|
||||
const newValue = Math.max(0, Math.min(10, (currentValue ?? 0) + direction));
|
||||
handleSelect(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Get NPS option color for color coding
|
||||
const getNPSOptionColor = (idx: number): string => {
|
||||
if (idx > 8) return "bg-emerald-100"; // 9-10: Promoters (green)
|
||||
if (idx > 6) return "bg-orange-100"; // 7-8: Passives (orange)
|
||||
return "bg-rose-100"; // 0-6: Detractors (red)
|
||||
};
|
||||
|
||||
// Render NPS option (0-10)
|
||||
const renderNPSOption = (number: number): React.JSX.Element => {
|
||||
const isSelected = currentValue === number;
|
||||
const isHovered = hoveredValue === number;
|
||||
const isLast = number === 10; // Last option is 10
|
||||
const isFirst = number === 0; // First option is 0
|
||||
|
||||
// Determine border radius classes
|
||||
let borderRadiusClasses = "";
|
||||
if (isLast) {
|
||||
borderRadiusClasses = detectedDir === "rtl" ? "rounded-l-input border-l" : "rounded-r-input border-r";
|
||||
} else if (isFirst) {
|
||||
borderRadiusClasses = detectedDir === "rtl" ? "rounded-r-input border-r" : "rounded-l-input border-l";
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
<label
|
||||
key={number}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown(number)}
|
||||
className={cn(
|
||||
"text-input-text font-input font-input-weight relative flex w-full cursor-pointer items-center justify-center overflow-hidden border-b border-l border-t transition-colors focus:border-2 focus:outline-none",
|
||||
isSelected ? "bg-brand-20 border-brand z-10 border-2" : "border-input-border bg-input-bg",
|
||||
borderRadiusClasses,
|
||||
isHovered && !isSelected && "bg-input-selected-bg",
|
||||
colorCoding ? "min-h-[47px]" : "min-h-[41px]",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
"focus:border-brand"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHoveredValue(null);
|
||||
}}>
|
||||
{colorCoding ? (
|
||||
<div className={cn("absolute left-0 top-0 h-[6px] w-full", getNPSOptionColor(number))} />
|
||||
) : null}
|
||||
<input
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={number}
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of 10`}
|
||||
/>
|
||||
<span className="text-sm">{number}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// Generate NPS options (0-10)
|
||||
const npsOptions = Array.from({ length: 11 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* NPS Options */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
<fieldset className="w-full">
|
||||
<legend className="sr-only">NPS rating options</legend>
|
||||
<div className="flex w-full">{npsOptions.map((number) => renderNPSOption(number))}</div>
|
||||
|
||||
{/* Labels */}
|
||||
{(lowerLabel ?? upperLabel) ? (
|
||||
<div className="mt-2 flex justify-between gap-8 px-1.5">
|
||||
{lowerLabel ? (
|
||||
<Label variant="default" className="max-w-[50%] text-xs leading-6" dir={detectedDir}>
|
||||
{lowerLabel}
|
||||
</Label>
|
||||
) : null}
|
||||
{upperLabel ? (
|
||||
<Label
|
||||
variant="default"
|
||||
className="max-w-[50%] text-right text-xs leading-6"
|
||||
dir={detectedDir}>
|
||||
{upperLabel}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NPS };
|
||||
export type { NPSProps };
|
||||
276
packages/survey-ui/src/components/elements/open-text.stories.tsx
Normal file
276
packages/survey-ui/src/components/elements/open-text.stories.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { OpenText, type OpenTextProps } from "./open-text";
|
||||
|
||||
type StoryProps = OpenTextProps &
|
||||
Partial<BaseStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/OpenText",
|
||||
component: OpenText,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete open text element that combines headline, description, and input/textarea components. Supports short and long answers, validation, character limits, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for the input field",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Current input value",
|
||||
table: { category: "State" },
|
||||
},
|
||||
longAnswer: {
|
||||
control: "boolean",
|
||||
description: "Use textarea for long-form answers instead of input",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
inputType: {
|
||||
control: { type: "select" },
|
||||
options: ["text", "email", "url", "phone", "number"],
|
||||
description: "Type of input field (only used when longAnswer is false)",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
charLimit: {
|
||||
control: "object",
|
||||
description: "Character limit configuration {min?, max?}",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
rows: {
|
||||
control: { type: "number", min: 1, max: 20 },
|
||||
description: "Number of rows for textarea (only when longAnswer is true)",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(OpenText),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "What's your feedback?",
|
||||
description: "Please share your thoughts with us",
|
||||
placeholder: "Type your answer here...",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontSize",
|
||||
"inputFontWeight",
|
||||
"inputWidth",
|
||||
"inputHeight",
|
||||
"inputBorderRadius",
|
||||
"inputPlaceholderColor",
|
||||
"inputPaddingX",
|
||||
"inputPaddingY",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "What's your feedback?",
|
||||
placeholder: "Type your answer here...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "What did you think of our service?",
|
||||
description: "We'd love to hear your honest feedback to help us improve",
|
||||
placeholder: "Share your thoughts...",
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "What's your email address?",
|
||||
description: "We'll use this to contact you",
|
||||
placeholder: "email@example.com",
|
||||
required: true,
|
||||
inputType: "email",
|
||||
},
|
||||
};
|
||||
|
||||
export const LongAnswer: Story = {
|
||||
args: {
|
||||
headline: "Tell us about your experience",
|
||||
description: "Please provide as much detail as possible",
|
||||
placeholder: "Write your detailed response here...",
|
||||
longAnswer: true,
|
||||
rows: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const LongAnswerWithCharLimit: Story = {
|
||||
args: {
|
||||
headline: "Share your story",
|
||||
description: "Maximum 500 characters",
|
||||
placeholder: "Tell us your story...",
|
||||
longAnswer: true,
|
||||
rows: 6,
|
||||
charLimit: {
|
||||
max: 500,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmailInput: Story = {
|
||||
args: {
|
||||
headline: "What's your email?",
|
||||
inputType: "email",
|
||||
placeholder: "email@example.com",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const PhoneInput: Story = {
|
||||
args: {
|
||||
headline: "What's your phone number?",
|
||||
description: "Include country code",
|
||||
inputType: "phone",
|
||||
placeholder: "+1 (555) 123-4567",
|
||||
},
|
||||
};
|
||||
|
||||
export const URLInput: Story = {
|
||||
args: {
|
||||
headline: "What's your website?",
|
||||
inputType: "url",
|
||||
placeholder: "https://example.com",
|
||||
},
|
||||
};
|
||||
|
||||
export const NumberInput: Story = {
|
||||
args: {
|
||||
headline: "How many employees does your company have?",
|
||||
inputType: "number",
|
||||
placeholder: "0",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "What's your email address?",
|
||||
inputType: "email",
|
||||
placeholder: "email@example.com",
|
||||
value: "invalid-email",
|
||||
errorMessage: "Please enter a valid email address",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
headline: "What's your name?",
|
||||
placeholder: "Enter your name",
|
||||
value: "John Doe",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This field is disabled",
|
||||
description: "You cannot edit this field",
|
||||
placeholder: "Disabled input",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledWithValue: Story = {
|
||||
args: {
|
||||
headline: "Submission ID",
|
||||
value: "SUB-2024-001",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو تقييمك؟",
|
||||
description: "يرجى مشاركة أفكارك معنا",
|
||||
placeholder: "اكتب إجابتك هنا...",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLLongAnswer: Story = {
|
||||
args: {
|
||||
headline: "أخبرنا عن تجربتك",
|
||||
description: "يرجى تقديم أكبر قدر ممكن من التفاصيل",
|
||||
placeholder: "اكتب ردك التفصيلي هنا...",
|
||||
longAnswer: true,
|
||||
rows: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithErrorAndRTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو بريدك الإلكتروني؟",
|
||||
inputType: "email",
|
||||
placeholder: "email@example.com",
|
||||
errorMessage: "يرجى إدخال عنوان بريد إلكتروني صالح",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<OpenText
|
||||
elementId="name"
|
||||
inputId="name"
|
||||
headline="What's your name?"
|
||||
placeholder="Enter your name"
|
||||
required
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<OpenText
|
||||
elementId="email"
|
||||
inputId="email"
|
||||
headline="What's your email?"
|
||||
inputType="email"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<OpenText
|
||||
elementId="bio"
|
||||
inputId="bio"
|
||||
headline="Tell us about yourself"
|
||||
description="Optional: Share a bit about your background"
|
||||
placeholder="Your bio..."
|
||||
longAnswer
|
||||
rows={4}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
117
packages/survey-ui/src/components/elements/open-text.tsx
Normal file
117
packages/survey-ui/src/components/elements/open-text.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { Textarea } from "@/components/general/textarea";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OpenTextProps {
|
||||
elementId: string;
|
||||
headline: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
inputId: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
longAnswer?: boolean;
|
||||
inputType?: "text" | "email" | "url" | "phone" | "number";
|
||||
charLimit?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
errorMessage?: string;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function OpenText({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
placeholder,
|
||||
value = "",
|
||||
inputId,
|
||||
onChange,
|
||||
required = false,
|
||||
longAnswer = false,
|
||||
inputType = "text",
|
||||
charLimit,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
rows = 3,
|
||||
disabled = false,
|
||||
}: OpenTextProps): React.JSX.Element {
|
||||
const [currentLength, setCurrentLength] = React.useState(value.length);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
const newValue = e.target.value;
|
||||
setCurrentLength(newValue.length);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const renderCharLimit = (): React.JSX.Element | null => {
|
||||
if (charLimit?.max === undefined) return null;
|
||||
const isOverLimit = currentLength >= charLimit.max;
|
||||
return (
|
||||
<span className={cn("text-xs", isOverLimit ? "font-semibold text-red-500" : "text-neutral-400")}>
|
||||
{currentLength}/{charLimit.max}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? "", placeholder ?? ""],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Input or Textarea */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
<div className="space-y-1">
|
||||
{longAnswer ? (
|
||||
<Textarea
|
||||
id={inputId}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
required={required}
|
||||
dir={detectedDir}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
minLength={charLimit?.min}
|
||||
maxLength={charLimit?.max}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
required={required}
|
||||
dir={detectedDir}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
minLength={charLimit?.min}
|
||||
maxLength={charLimit?.max}
|
||||
/>
|
||||
)}
|
||||
{renderCharLimit()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { OpenText };
|
||||
export type { OpenTextProps };
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
} from "../../lib/story-helpers";
|
||||
import { PictureSelect, type PictureSelectOption, type PictureSelectProps } from "./picture-select";
|
||||
|
||||
type StoryProps = PictureSelectProps & Partial<BaseStylingOptions & { optionBorderRadius: string }>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/PictureSelect",
|
||||
component: PictureSelect,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete picture selection element that combines headline, description, and a grid of selectable images. Supports both single and multi-select modes, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of picture options to choose from",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Selected option ID(s) - string for single select, string[] for multi select",
|
||||
table: { category: "State" },
|
||||
},
|
||||
allowMulti: {
|
||||
control: "boolean",
|
||||
description: "Whether multiple selections are allowed",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(PictureSelect),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
// Sample image URLs - using placeholder images
|
||||
const defaultOptions: PictureSelectOption[] = [
|
||||
{
|
||||
id: "option-1",
|
||||
imageUrl: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=300&h=200&fit=crop",
|
||||
alt: "Mountain landscape",
|
||||
},
|
||||
{
|
||||
id: "option-2",
|
||||
imageUrl: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=300&h=200&fit=crop",
|
||||
alt: "Ocean view",
|
||||
},
|
||||
{
|
||||
id: "option-3",
|
||||
imageUrl: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=300&h=200&fit=crop",
|
||||
alt: "Forest path",
|
||||
},
|
||||
{
|
||||
id: "option-4",
|
||||
imageUrl: "https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=300&h=200&fit=crop",
|
||||
alt: "Desert scene",
|
||||
},
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
description: "Select one or more images",
|
||||
options: defaultOptions,
|
||||
allowMulti: false,
|
||||
},
|
||||
argTypes: {
|
||||
// Element styling
|
||||
elementHeadlineFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
brandColor: {
|
||||
control: "color",
|
||||
table: { category: "Survey Styling" },
|
||||
},
|
||||
optionBorderRadius: {
|
||||
control: "text",
|
||||
description: "Border radius for picture options",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "Select your favorite travel destination",
|
||||
description: "Choose the image that appeals to you most",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleSelect: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
description: "Select one image",
|
||||
options: defaultOptions,
|
||||
allowMulti: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: Story = {
|
||||
args: {
|
||||
headline: "Select all images you like",
|
||||
description: "You can select multiple images",
|
||||
options: defaultOptions,
|
||||
allowMulti: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
description: "Please select an image",
|
||||
options: defaultOptions,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithMultipleSelections: Story = {
|
||||
args: {
|
||||
headline: "Select all images you like",
|
||||
description: "You can select multiple images",
|
||||
options: defaultOptions,
|
||||
allowMulti: true,
|
||||
value: ["option-1", "option-3"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Which image do you prefer?",
|
||||
description: "Please select an image",
|
||||
options: defaultOptions,
|
||||
errorMessage: "Please select at least one image",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
headline: "Select your favorite images",
|
||||
description: "Choose from the images below",
|
||||
options: [
|
||||
...defaultOptions,
|
||||
{
|
||||
id: "option-5",
|
||||
imageUrl: "https://images.unsplash.com/photo-1519681393784-d120267933ba?w=300&h=200&fit=crop",
|
||||
alt: "City skyline",
|
||||
},
|
||||
{
|
||||
id: "option-6",
|
||||
imageUrl: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=300&h=200&fit=crop",
|
||||
alt: "Sunset",
|
||||
},
|
||||
],
|
||||
allowMulti: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هي الصورة التي تفضلها؟",
|
||||
description: "اختر صورة واحدة",
|
||||
options: defaultOptions.map((opt) => ({ ...opt, alt: "نص بديل" })),
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelection: Story = {
|
||||
args: {
|
||||
headline: "اختر الصور التي تعجبك",
|
||||
description: "يمكنك اختيار عدة صور",
|
||||
options: defaultOptions.map((opt) => ({ ...opt, alt: "نص بديل" })),
|
||||
allowMulti: true,
|
||||
value: ["option-1", "option-2"],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => {
|
||||
const [value1, setValue1] = React.useState<string | string[] | undefined>(undefined);
|
||||
const [value2, setValue2] = React.useState<string | string[]>(["option-1", "option-3"]);
|
||||
|
||||
return (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<PictureSelect
|
||||
elementId="picture-1"
|
||||
inputId="picture-1-input"
|
||||
headline="Which image do you prefer?"
|
||||
description="Select one image"
|
||||
options={defaultOptions}
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
/>
|
||||
<PictureSelect
|
||||
elementId="picture-2"
|
||||
inputId="picture-2-input"
|
||||
headline="Select all images you like"
|
||||
description="You can select multiple images"
|
||||
options={defaultOptions}
|
||||
allowMulti
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
219
packages/survey-ui/src/components/elements/picture-select.tsx
Normal file
219
packages/survey-ui/src/components/elements/picture-select.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import * as React from "react";
|
||||
import { Checkbox } from "@/components/general/checkbox";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { RadioGroupItem } from "@/components/general/radio-group";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Picture option for picture select element
|
||||
*/
|
||||
export interface PictureSelectOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** URL of the image */
|
||||
imageUrl: string;
|
||||
/** Alt text for the image */
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface PictureSelectProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the picture select group */
|
||||
inputId: string;
|
||||
/** Array of picture options to choose from */
|
||||
options: PictureSelectOption[];
|
||||
/** Currently selected option ID(s) - string for single select, string[] for multi select */
|
||||
value?: string | string[];
|
||||
/** Callback function called when selection changes */
|
||||
onChange: (value: string | string[]) => void;
|
||||
/** Whether multiple selections are allowed */
|
||||
allowMulti?: boolean;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function PictureSelect({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
allowMulti = false,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: PictureSelectProps): React.JSX.Element {
|
||||
// Ensure value is always the correct type
|
||||
let selectedValues: string[] | string | undefined;
|
||||
if (allowMulti) {
|
||||
selectedValues = Array.isArray(value) ? value : [];
|
||||
} else {
|
||||
selectedValues = typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
const handleOptionChange = (optionId: string): void => {
|
||||
if (disabled) return;
|
||||
|
||||
if (allowMulti) {
|
||||
const currentArray = Array.isArray(value) ? value : [];
|
||||
const newValue = currentArray.includes(optionId)
|
||||
? currentArray.filter((id) => id !== optionId)
|
||||
: [...currentArray, optionId];
|
||||
onChange(newValue);
|
||||
} else {
|
||||
// Single select - toggle if same option, otherwise select new one
|
||||
const newValue = selectedValues === optionId ? undefined : optionId;
|
||||
onChange(newValue ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? "", ...options.map((opt) => opt.alt ?? "")],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Picture Grid - 2 columns */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
{allowMulti ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = (selectedValues as string[]).includes(option.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={() => {
|
||||
handleOptionChange(option.id);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && !disabled) {
|
||||
e.preventDefault();
|
||||
handleOptionChange(option.id);
|
||||
}
|
||||
}}
|
||||
aria-pressed={isSelected}
|
||||
aria-disabled={disabled}>
|
||||
{/* Image container with border when selected */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-option absolute inset-[2px] overflow-hidden",
|
||||
isSelected && "border-brand border-4 border-solid"
|
||||
)}>
|
||||
<img
|
||||
src={option.imageUrl}
|
||||
alt={option.alt ?? `Option ${option.id}`}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{/* Selection indicator - Checkbox for multi select */}
|
||||
<div className="absolute right-[5%] top-[5%]">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => {
|
||||
handleOptionChange(option.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4"
|
||||
aria-label={option.alt ?? `Select ${option.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<RadioGroupPrimitive.Root
|
||||
value={selectedValues as string}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
className="grid grid-cols-2 gap-2">
|
||||
{options.map((option) => {
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
const isSelected = selectedValues === option.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"rounded-option relative aspect-[162/97] w-full cursor-pointer transition-all",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={() => {
|
||||
handleOptionChange(option.id);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && !disabled) {
|
||||
e.preventDefault();
|
||||
handleOptionChange(option.id);
|
||||
}
|
||||
}}
|
||||
aria-pressed={isSelected}
|
||||
aria-disabled={disabled}>
|
||||
{/* Image container with border when selected */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-option absolute inset-[2px] overflow-hidden",
|
||||
isSelected && "border-brand border-4 border-solid"
|
||||
)}>
|
||||
<img
|
||||
src={option.imageUrl}
|
||||
alt={option.alt ?? `Option ${option.id}`}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{/* Selection indicator - Radio button for single select */}
|
||||
<div className="absolute right-[5%] top-[5%]">
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={disabled}
|
||||
className="h-4 w-4 bg-white"
|
||||
aria-label={option.alt ?? `Select ${option.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroupPrimitive.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PictureSelect };
|
||||
export type { PictureSelectProps };
|
||||
220
packages/survey-ui/src/components/elements/ranking.stories.tsx
Normal file
220
packages/survey-ui/src/components/elements/ranking.stories.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type OptionStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
optionStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Ranking, type RankingOption, type RankingProps } from "./ranking";
|
||||
|
||||
type StoryProps = RankingProps & Partial<BaseStylingOptions & OptionStylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Ranking",
|
||||
component: Ranking,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A ranking element that allows users to order items by clicking them. Users can reorder ranked items using up/down buttons.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of options to rank",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Currently ranked option IDs in order",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Ranking),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
// Default options for stories
|
||||
const defaultOptions: RankingOption[] = [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
{ id: "3", label: "Option 3" },
|
||||
{ id: "4", label: "Option 4" },
|
||||
{ id: "5", label: "Option 5" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "ranking-1",
|
||||
inputId: "ranking-input-1",
|
||||
headline: "Rank these items in order of importance",
|
||||
description: "Click items to add them to your ranking, then use arrows to reorder",
|
||||
options: defaultOptions,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...optionStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "ranking-1",
|
||||
inputId: "ranking-input-1",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "ranking-2",
|
||||
inputId: "ranking-input-2",
|
||||
headline: "Rank these items in order of importance",
|
||||
description: "Click items to add them to your ranking, then use the arrows to reorder them",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRanking: Story = {
|
||||
args: {
|
||||
elementId: "ranking-3",
|
||||
inputId: "ranking-input-3",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
value: ["3", "1", "5"],
|
||||
},
|
||||
};
|
||||
|
||||
export const FullyRanked: Story = {
|
||||
args: {
|
||||
elementId: "ranking-4",
|
||||
inputId: "ranking-input-4",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
value: ["5", "2", "1", "4", "3"],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "ranking-5",
|
||||
inputId: "ranking-input-5",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "ranking-6",
|
||||
inputId: "ranking-input-6",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
required: true,
|
||||
errorMessage: "Please rank all items",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "ranking-7",
|
||||
inputId: "ranking-input-7",
|
||||
headline: "Rank these items in order of importance",
|
||||
options: defaultOptions,
|
||||
value: ["2", "4"],
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
elementId: "ranking-8",
|
||||
inputId: "ranking-input-8",
|
||||
headline: "Rank these features by priority",
|
||||
description: "Click to add to ranking, use arrows to reorder",
|
||||
options: [
|
||||
{ id: "1", label: "Feature A" },
|
||||
{ id: "2", label: "Feature B" },
|
||||
{ id: "3", label: "Feature C" },
|
||||
{ id: "4", label: "Feature D" },
|
||||
{ id: "5", label: "Feature E" },
|
||||
{ id: "6", label: "Feature F" },
|
||||
{ id: "7", label: "Feature G" },
|
||||
{ id: "8", label: "Feature H" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "ranking-rtl",
|
||||
inputId: "ranking-input-rtl",
|
||||
headline: "رتب هذه العناصر حسب الأهمية",
|
||||
description: "انقر على العناصر لإضافتها إلى الترتيب، ثم استخدم الأسهم لإعادة الترتيب",
|
||||
options: [
|
||||
{ id: "1", label: "الخيار الأول" },
|
||||
{ id: "2", label: "الخيار الثاني" },
|
||||
{ id: "3", label: "الخيار الثالث" },
|
||||
{ id: "4", label: "الخيار الرابع" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithRanking: Story = {
|
||||
args: {
|
||||
elementId: "ranking-rtl-ranked",
|
||||
inputId: "ranking-input-rtl-ranked",
|
||||
headline: "رتب هذه العناصر حسب الأهمية",
|
||||
options: [
|
||||
{ id: "1", label: "الخيار الأول" },
|
||||
{ id: "2", label: "الخيار الثاني" },
|
||||
{ id: "3", label: "الخيار الثالث" },
|
||||
{ id: "4", label: "الخيار الرابع" },
|
||||
],
|
||||
value: ["3", "1", "4"],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Ranking
|
||||
elementId="ranking-1"
|
||||
inputId="ranking-input-1"
|
||||
headline="Rank these features by priority"
|
||||
options={defaultOptions}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Ranking
|
||||
elementId="ranking-2"
|
||||
inputId="ranking-input-2"
|
||||
headline="Rank these products by preference"
|
||||
description="Click items to rank them"
|
||||
options={[
|
||||
{ id: "1", label: "Product A" },
|
||||
{ id: "2", label: "Product B" },
|
||||
{ id: "3", label: "Product C" },
|
||||
]}
|
||||
value={["2", "1"]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
238
packages/survey-ui/src/components/elements/ranking.tsx
Normal file
238
packages/survey-ui/src/components/elements/ranking.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Option for ranking element
|
||||
*/
|
||||
export interface RankingOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface RankingProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the ranking group */
|
||||
inputId: string;
|
||||
/** Array of options to rank */
|
||||
options: RankingOption[];
|
||||
/** Currently ranked option IDs in order (array of option IDs) */
|
||||
value?: string[];
|
||||
/** Callback function called when ranking changes */
|
||||
onChange: (value: string[]) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Ranking({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: RankingProps): React.JSX.Element {
|
||||
// Ensure value is always an array
|
||||
const rankedIds = React.useMemo(() => (Array.isArray(value) ? value : []), [value]);
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? "", ...options.map((opt) => opt.label)],
|
||||
});
|
||||
|
||||
// Get sorted (ranked) items and unsorted items
|
||||
const sortedItems = React.useMemo(() => {
|
||||
return rankedIds
|
||||
.map((id) => options.find((opt) => opt.id === id))
|
||||
.filter((item): item is RankingOption => item !== undefined);
|
||||
}, [rankedIds, options]);
|
||||
|
||||
const unsortedItems = React.useMemo(() => {
|
||||
return options.filter((opt) => !rankedIds.includes(opt.id));
|
||||
}, [options, rankedIds]);
|
||||
|
||||
// Handle item click (add to ranking or remove from ranking)
|
||||
const handleItemClick = (item: RankingOption): void => {
|
||||
if (disabled) return;
|
||||
|
||||
const isAlreadyRanked = rankedIds.includes(item.id);
|
||||
const newRankedIds = isAlreadyRanked ? rankedIds.filter((id) => id !== item.id) : [...rankedIds, item.id];
|
||||
|
||||
onChange(newRankedIds);
|
||||
};
|
||||
|
||||
// Handle move up/down
|
||||
const handleMove = (itemId: string, direction: "up" | "down"): void => {
|
||||
if (disabled) return;
|
||||
|
||||
const index = rankedIds.findIndex((id) => id === itemId);
|
||||
if (index === -1) return;
|
||||
|
||||
const newRankedIds = [...rankedIds];
|
||||
const [movedItem] = newRankedIds.splice(index, 1);
|
||||
const newIndex = direction === "up" ? Math.max(0, index - 1) : Math.min(newRankedIds.length, index + 1);
|
||||
newRankedIds.splice(newIndex, 0, movedItem);
|
||||
|
||||
onChange(newRankedIds);
|
||||
};
|
||||
|
||||
// Combine sorted and unsorted items for display
|
||||
const allItems = [...sortedItems, ...unsortedItems];
|
||||
|
||||
// Animation ref for smooth transitions
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Ranking Options */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
|
||||
<fieldset className="w-full" dir={detectedDir}>
|
||||
<legend className="sr-only">Ranking options</legend>
|
||||
<div className="space-y-2" ref={parent as React.Ref<HTMLDivElement>}>
|
||||
{allItems.map((item) => {
|
||||
const isRanked = rankedIds.includes(item.id);
|
||||
const rankIndex = rankedIds.findIndex((id) => id === item.id);
|
||||
const isFirst = isRanked && rankIndex === 0;
|
||||
const isLast = isRanked && rankIndex === rankedIds.length - 1;
|
||||
const displayNumber = isRanked ? rankIndex + 1 : undefined;
|
||||
|
||||
// RTL-aware padding class
|
||||
const paddingClass = detectedDir === "rtl" ? "pr-3" : "pl-3";
|
||||
|
||||
// RTL-aware border class for control buttons
|
||||
const borderClass = detectedDir === "rtl" ? "border-r" : "border-l";
|
||||
|
||||
// RTL-aware border radius classes for control buttons
|
||||
let topButtonRadiusClass = "rounded-tr-md";
|
||||
if (isFirst) {
|
||||
topButtonRadiusClass = "cursor-not-allowed opacity-30";
|
||||
} else if (detectedDir === "rtl") {
|
||||
topButtonRadiusClass = "rounded-tl-md";
|
||||
}
|
||||
|
||||
let bottomButtonRadiusClass = "rounded-br-md";
|
||||
if (isLast) {
|
||||
bottomButtonRadiusClass = "cursor-not-allowed opacity-30";
|
||||
} else if (detectedDir === "rtl") {
|
||||
bottomButtonRadiusClass = "rounded-bl-md";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"rounded-option flex h-12 cursor-pointer items-center border transition-all",
|
||||
paddingClass,
|
||||
"bg-option-bg border-option-border",
|
||||
"hover:bg-option-hover-bg focus-within:border-brand focus-within:bg-option-selected-bg focus-within:shadow-sm",
|
||||
isRanked && "bg-option-selected-bg border-brand",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleItemClick(item);
|
||||
}}
|
||||
disabled={disabled}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleItemClick(item);
|
||||
}
|
||||
}}
|
||||
className="group flex h-full grow items-center gap-4 text-start focus:outline-none"
|
||||
aria-label={
|
||||
isRanked ? `Remove ${item.label} from ranking` : `Add ${item.label} to ranking`
|
||||
}>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold",
|
||||
isRanked
|
||||
? "bg-brand border-brand text-white"
|
||||
: "border-option-border group-hover:bg-background group-hover:text-foreground border-dashed text-transparent"
|
||||
)}>
|
||||
{displayNumber}
|
||||
</span>
|
||||
<span
|
||||
className="font-option text-option font-option-weight text-option-label shrink grow text-start"
|
||||
dir={detectedDir}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Up/Down buttons for ranked items */}
|
||||
{isRanked ? (
|
||||
<div className={cn("border-option-border flex h-full grow-0 flex-col", borderClass)}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isFirst ? -1 : 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMove(item.id, "up");
|
||||
}}
|
||||
disabled={isFirst || disabled}
|
||||
aria-label={`Move ${item.label} up`}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center px-2 transition-colors",
|
||||
topButtonRadiusClass
|
||||
)}>
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isLast ? -1 : 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMove(item.id, "down");
|
||||
}}
|
||||
disabled={isLast || disabled}
|
||||
aria-label={`Move ${item.label} down`}
|
||||
className={cn(
|
||||
"border-option-border flex flex-1 items-center justify-center border-t px-2 transition-colors",
|
||||
bottomButtonRadiusClass
|
||||
)}>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Ranking };
|
||||
export type { RankingProps };
|
||||
318
packages/survey-ui/src/components/elements/rating.stories.tsx
Normal file
318
packages/survey-ui/src/components/elements/rating.stories.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Rating, type RatingProps } from "./rating";
|
||||
|
||||
type StoryProps = RatingProps & Partial<BaseStylingOptions & LabelStylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Rating",
|
||||
component: Rating,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A rating element that supports number, star, and smiley scales. Users can select a rating from 1 to the specified range.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
scale: {
|
||||
control: { type: "select" },
|
||||
options: ["number", "star", "smiley"],
|
||||
description: "Rating scale type",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
range: {
|
||||
control: { type: "select" },
|
||||
options: [3, 4, 5, 6, 7, 10],
|
||||
description: "Number of rating options",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: { type: "number", min: 1 },
|
||||
description: "Currently selected rating value",
|
||||
table: { category: "State" },
|
||||
},
|
||||
lowerLabel: {
|
||||
control: "text",
|
||||
description: "Label for the lower end of the scale",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
upperLabel: {
|
||||
control: "text",
|
||||
description: "Label for the upper end of the scale",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
colorCoding: {
|
||||
control: "boolean",
|
||||
description: "Whether color coding is enabled (for smiley scale)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Rating),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "rating-1",
|
||||
inputId: "rating-input-1",
|
||||
headline: "How satisfied are you?",
|
||||
description: "Please rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: "Not satisfied",
|
||||
upperLabel: "Very satisfied",
|
||||
elementHeadlineFontFamily: "system-ui, sans-serif",
|
||||
elementHeadlineFontSize: "1.125rem",
|
||||
elementHeadlineFontWeight: "600",
|
||||
elementHeadlineColor: "#1e293b",
|
||||
elementDescriptionFontFamily: "system-ui, sans-serif",
|
||||
elementDescriptionFontSize: "0.875rem",
|
||||
elementDescriptionFontWeight: "400",
|
||||
elementDescriptionColor: "#64748b",
|
||||
labelFontFamily: "system-ui, sans-serif",
|
||||
labelFontSize: "0.75rem",
|
||||
labelFontWeight: "400",
|
||||
labelColor: "#64748b",
|
||||
labelOpacity: "1",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontWeight",
|
||||
"inputBorderRadius",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps & Record<string, unknown>>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "rating-1",
|
||||
inputId: "rating-input-1",
|
||||
headline: "How satisfied are you?",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "rating-2",
|
||||
inputId: "rating-input-2",
|
||||
headline: "How satisfied are you?",
|
||||
description: "Please rate your experience from 1 to 5",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const NumberScale: Story = {
|
||||
args: {
|
||||
elementId: "rating-number",
|
||||
inputId: "rating-input-number",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const StarScale: Story = {
|
||||
args: {
|
||||
elementId: "rating-star",
|
||||
inputId: "rating-input-star",
|
||||
headline: "Rate this product",
|
||||
scale: "star",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const SmileyScale: Story = {
|
||||
args: {
|
||||
elementId: "rating-smiley",
|
||||
inputId: "rating-input-smiley",
|
||||
headline: "How do you feel?",
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabels: Story = {
|
||||
args: {
|
||||
elementId: "rating-labels",
|
||||
inputId: "rating-input-labels",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: "Not satisfied",
|
||||
upperLabel: "Very satisfied",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
elementId: "rating-selection",
|
||||
inputId: "rating-input-selection",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
value: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "rating-required",
|
||||
inputId: "rating-input-required",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "rating-error",
|
||||
inputId: "rating-input-error",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
required: true,
|
||||
errorMessage: "Please select a rating",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "rating-disabled",
|
||||
inputId: "rating-input-disabled",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
value: 3,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Range3: Story = {
|
||||
args: {
|
||||
elementId: "rating-range3",
|
||||
inputId: "rating-input-range3",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 3,
|
||||
},
|
||||
};
|
||||
|
||||
export const Range10: Story = {
|
||||
args: {
|
||||
elementId: "rating-range10",
|
||||
inputId: "rating-input-range10",
|
||||
headline: "Rate your experience",
|
||||
scale: "number",
|
||||
range: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const ColorCoding: Story = {
|
||||
args: {
|
||||
elementId: "rating-color",
|
||||
inputId: "rating-input-color",
|
||||
headline: "How do you feel?",
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
colorCoding: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "rating-rtl",
|
||||
inputId: "rating-input-rtl",
|
||||
headline: "كيف تقيم تجربتك؟",
|
||||
description: "يرجى تقييم تجربتك من 1 إلى 5",
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: "غير راض",
|
||||
upperLabel: "راض جداً",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelection: Story = {
|
||||
args: {
|
||||
elementId: "rating-rtl-selection",
|
||||
inputId: "rating-input-rtl-selection",
|
||||
headline: "كيف تقيم تجربتك؟",
|
||||
scale: "star",
|
||||
range: 5,
|
||||
value: 4,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => {
|
||||
const [value1, setValue1] = React.useState<number | undefined>(undefined);
|
||||
const [value2, setValue2] = React.useState<number | undefined>(undefined);
|
||||
const [value3, setValue3] = React.useState<number | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Rating
|
||||
elementId="rating-1"
|
||||
inputId="rating-input-1"
|
||||
headline="How satisfied are you with our service?"
|
||||
scale="number"
|
||||
range={5}
|
||||
lowerLabel="Not satisfied"
|
||||
upperLabel="Very satisfied"
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
/>
|
||||
<Rating
|
||||
elementId="rating-2"
|
||||
inputId="rating-input-2"
|
||||
headline="Rate this product"
|
||||
description="Please rate from 1 to 5 stars"
|
||||
scale="star"
|
||||
range={5}
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
/>
|
||||
<Rating
|
||||
elementId="rating-3"
|
||||
inputId="rating-input-3"
|
||||
headline="How do you feel?"
|
||||
scale="smiley"
|
||||
range={5}
|
||||
colorCoding
|
||||
value={value3}
|
||||
onChange={setValue3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
444
packages/survey-ui/src/components/elements/rating.tsx
Normal file
444
packages/survey-ui/src/components/elements/rating.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import { Star } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Label } from "@/components/general/label";
|
||||
import {
|
||||
ConfusedFace,
|
||||
FrowningFace,
|
||||
GrinningFaceWithSmilingEyes,
|
||||
GrinningSquintingFace,
|
||||
NeutralFace,
|
||||
PerseveringFace,
|
||||
SlightlySmilingFace,
|
||||
SmilingFaceWithSmilingEyes,
|
||||
TiredFace,
|
||||
WearyFace,
|
||||
} from "@/components/general/smileys";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Get smiley color class based on range and index
|
||||
*/
|
||||
const getSmileyColor = (range: number, idx: number): string => {
|
||||
if (range > 5) {
|
||||
if (range - idx < 3) return "fill-emerald-100";
|
||||
if (range - idx < 5) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
} else if (range < 5) {
|
||||
if (range - idx < 2) return "fill-emerald-100";
|
||||
if (range - idx < 3) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
}
|
||||
if (range - idx < 3) return "fill-emerald-100";
|
||||
if (range - idx < 4) return "fill-orange-100";
|
||||
return "fill-rose-100";
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active smiley color class based on range and index
|
||||
*/
|
||||
const getActiveSmileyColor = (range: number, idx: number): string => {
|
||||
if (range > 5) {
|
||||
if (range - idx < 3) return "fill-emerald-300";
|
||||
if (range - idx < 5) return "fill-orange-300";
|
||||
return "fill-rose-300";
|
||||
} else if (range < 5) {
|
||||
if (range - idx < 2) return "fill-emerald-300";
|
||||
if (range - idx < 3) return "fill-orange-300";
|
||||
return "fill-rose-300";
|
||||
}
|
||||
if (range - idx < 3) return "fill-emerald-300";
|
||||
if (range - idx < 4) return "fill-orange-300";
|
||||
return "fill-rose-300";
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the appropriate smiley icon based on range and index
|
||||
*/
|
||||
const getSmileyIcon = (
|
||||
iconIdx: number,
|
||||
idx: number,
|
||||
range: number,
|
||||
active: boolean,
|
||||
addColors: boolean
|
||||
): React.JSX.Element => {
|
||||
const activeColor = addColors ? getActiveSmileyColor(range, idx) : "fill-yellow-200";
|
||||
const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none";
|
||||
|
||||
const icons = [
|
||||
<TiredFace key="tired-face" className={active ? activeColor : inactiveColor} />,
|
||||
<WearyFace key="weary-face" className={active ? activeColor : inactiveColor} />,
|
||||
<PerseveringFace key="persevering-face" className={active ? activeColor : inactiveColor} />,
|
||||
<FrowningFace key="frowning-face" className={active ? activeColor : inactiveColor} />,
|
||||
<ConfusedFace key="confused-face" className={active ? activeColor : inactiveColor} />,
|
||||
<NeutralFace key="neutral-face" className={active ? activeColor : inactiveColor} />,
|
||||
<SlightlySmilingFace key="slightly-smiling-face" className={active ? activeColor : inactiveColor} />,
|
||||
<SmilingFaceWithSmilingEyes
|
||||
key="smiling-face-with-smiling-eyes"
|
||||
className={active ? activeColor : inactiveColor}
|
||||
/>,
|
||||
<GrinningFaceWithSmilingEyes
|
||||
key="grinning-face-with-smiling-eyes"
|
||||
className={active ? activeColor : inactiveColor}
|
||||
/>,
|
||||
<GrinningSquintingFace key="grinning-squinting-face" className={active ? activeColor : inactiveColor} />,
|
||||
];
|
||||
return icons[iconIdx];
|
||||
};
|
||||
|
||||
/**
|
||||
* Smiley component for rating scale
|
||||
*/
|
||||
const RatingSmiley = ({
|
||||
active,
|
||||
idx,
|
||||
range,
|
||||
addColors = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
idx: number;
|
||||
range: number;
|
||||
addColors?: boolean;
|
||||
}): React.JSX.Element => {
|
||||
let iconsIdx: number[] = [];
|
||||
if (range === 10) iconsIdx = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
else if (range === 7) iconsIdx = [1, 3, 4, 5, 6, 8, 9];
|
||||
else if (range === 6) iconsIdx = [0, 2, 4, 5, 7, 9];
|
||||
else if (range === 5) iconsIdx = [3, 4, 5, 6, 7];
|
||||
else if (range === 4) iconsIdx = [4, 5, 6, 7];
|
||||
else if (range === 3) iconsIdx = [4, 5, 7];
|
||||
|
||||
return getSmileyIcon(iconsIdx[idx], idx, range, active, addColors);
|
||||
};
|
||||
|
||||
interface RatingProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the rating group */
|
||||
inputId: string;
|
||||
/** Rating scale type: 'number', 'star', or 'smiley' */
|
||||
scale: "number" | "star" | "smiley";
|
||||
/** Number of rating options (3, 4, 5, 6, 7, or 10) */
|
||||
range: 3 | 4 | 5 | 6 | 7 | 10;
|
||||
/** Currently selected rating value (1 to range) */
|
||||
value?: number;
|
||||
/** Callback function called when rating changes */
|
||||
onChange: (value: number) => void;
|
||||
/** Optional label for the lower end of the scale */
|
||||
lowerLabel?: string;
|
||||
/** Optional label for the upper end of the scale */
|
||||
upperLabel?: string;
|
||||
/** Whether color coding is enabled (for smiley scale) */
|
||||
colorCoding?: boolean;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the controls are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Rating({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
scale,
|
||||
range,
|
||||
value,
|
||||
onChange,
|
||||
lowerLabel,
|
||||
upperLabel,
|
||||
colorCoding = false,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: RatingProps): React.JSX.Element {
|
||||
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
||||
|
||||
// Ensure value is within valid range
|
||||
const currentValue = value && value >= 1 && value <= range ? value : undefined;
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [headline, description ?? "", lowerLabel ?? "", upperLabel ?? ""],
|
||||
});
|
||||
|
||||
// Handle rating selection
|
||||
const handleSelect = (ratingValue: number): void => {
|
||||
if (!disabled) {
|
||||
onChange(ratingValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (ratingValue: number) => (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelect(ratingValue);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
const direction = e.key === "ArrowLeft" ? -1 : 1;
|
||||
const newValue = Math.max(1, Math.min(range, (currentValue ?? 1) + direction));
|
||||
handleSelect(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Get number option color for color coding
|
||||
const getRatingNumberOptionColor = (ratingRange: number, idx: number): string => {
|
||||
if (ratingRange > 5) {
|
||||
if (ratingRange - idx < 2) return "bg-emerald-100";
|
||||
if (ratingRange - idx < 4) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
} else if (ratingRange < 5) {
|
||||
if (ratingRange - idx < 1) return "bg-emerald-100";
|
||||
if (ratingRange - idx < 2) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
}
|
||||
if (ratingRange - idx < 2) return "bg-emerald-100";
|
||||
if (ratingRange - idx < 3) return "bg-orange-100";
|
||||
return "bg-rose-100";
|
||||
};
|
||||
|
||||
// Render number scale option
|
||||
const renderNumberOption = (number: number, totalLength: number): React.JSX.Element => {
|
||||
const isSelected = currentValue === number;
|
||||
const isHovered = hoveredValue === number;
|
||||
const isLast = totalLength === number;
|
||||
const isFirst = number === 1;
|
||||
|
||||
// Determine border radius classes
|
||||
let borderRadiusClasses = "";
|
||||
if (isLast) {
|
||||
borderRadiusClasses = detectedDir === "rtl" ? "rounded-l-input border-l" : "rounded-r-input border-r";
|
||||
} else if (isFirst) {
|
||||
borderRadiusClasses = detectedDir === "rtl" ? "rounded-r-input border-r" : "rounded-l-input border-l";
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
<label
|
||||
key={number}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown(number)}
|
||||
className={cn(
|
||||
"text-input-text font-input font-input-weight relative flex w-full cursor-pointer items-center justify-center overflow-hidden border-b border-l border-t transition-colors focus:border-2 focus:outline-none",
|
||||
isSelected ? "bg-brand-20 border-brand z-10 border-2" : "border-input-border bg-input-bg",
|
||||
borderRadiusClasses,
|
||||
isHovered && !isSelected && "bg-input-selected-bg",
|
||||
colorCoding ? "min-h-[47px]" : "min-h-[41px]",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
"focus:border-brand"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHoveredValue(null);
|
||||
}}>
|
||||
{colorCoding ? (
|
||||
<div
|
||||
className={cn("absolute left-0 top-0 h-[6px] w-full", getRatingNumberOptionColor(range, number))}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={number}
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)}`}
|
||||
/>
|
||||
<span className="text-sm">{number}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// Render star scale option
|
||||
const renderStarOption = (number: number): React.JSX.Element => {
|
||||
const isSelected = currentValue === number;
|
||||
// Fill all stars up to the hovered value (if hovering) or selected value
|
||||
const activeValue = hoveredValue ?? currentValue ?? 0;
|
||||
const isActive = number <= activeValue;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
<label
|
||||
key={number}
|
||||
className={cn(
|
||||
"flex min-h-[48px] flex-1 cursor-pointer items-center justify-center transition-opacity",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown(number)}>
|
||||
<input
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={number}
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)} stars`}
|
||||
/>
|
||||
<div className="flex w-full max-w-[74px] items-center justify-center">
|
||||
{isActive ? (
|
||||
<Star className="h-full w-full fill-yellow-400 text-yellow-400 transition-colors" />
|
||||
) : (
|
||||
<Star className="h-full w-full text-slate-300 transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// Render smiley scale option
|
||||
const renderSmileyOption = (number: number, index: number): React.JSX.Element => {
|
||||
const isSelected = currentValue === number;
|
||||
const isHovered = hoveredValue === number;
|
||||
const isActive = isSelected || isHovered;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive
|
||||
<label
|
||||
key={number}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={handleKeyDown(number)}
|
||||
className={cn(
|
||||
"relative flex max-h-16 min-h-9 w-full cursor-pointer justify-center transition-colors focus:outline-none",
|
||||
isActive
|
||||
? "stroke-brand text-brand"
|
||||
: "stroke-muted-foreground text-muted-foreground focus:border-accent focus:border-2",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredValue(null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!disabled) {
|
||||
setHoveredValue(number);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHoveredValue(null);
|
||||
}}>
|
||||
<input
|
||||
type="radio"
|
||||
name={inputId}
|
||||
value={number}
|
||||
checked={isSelected}
|
||||
onChange={() => {
|
||||
handleSelect(number);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
className="sr-only"
|
||||
aria-label={`Rate ${String(number)} out of ${String(range)}`}
|
||||
/>
|
||||
<div className="h-full w-full max-w-[74px] object-contain">
|
||||
<RatingSmiley active={isActive} idx={index} range={range} addColors={colorCoding} />
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
// Generate rating options
|
||||
const ratingOptions = Array.from({ length: range }, (_, i) => i + 1);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Rating Options */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
<fieldset className="w-full">
|
||||
<legend className="sr-only">Rating options</legend>
|
||||
<div className="flex w-full">
|
||||
{ratingOptions.map((number, index) => {
|
||||
if (scale === "number") {
|
||||
return renderNumberOption(number, ratingOptions.length);
|
||||
} else if (scale === "star") {
|
||||
return renderStarOption(number);
|
||||
}
|
||||
return renderSmileyOption(number, index);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{(lowerLabel ?? upperLabel) ? (
|
||||
<div className="mt-4 flex justify-between gap-8 px-1.5">
|
||||
{lowerLabel ? (
|
||||
<Label variant="default" className="max-w-[50%] text-xs leading-6" dir={detectedDir}>
|
||||
{lowerLabel}
|
||||
</Label>
|
||||
) : null}
|
||||
{upperLabel ? (
|
||||
<Label
|
||||
variant="default"
|
||||
className="max-w-[50%] text-right text-xs leading-6"
|
||||
dir={detectedDir}>
|
||||
{upperLabel}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Rating };
|
||||
export type { RatingProps };
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
type OptionStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
optionStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { SingleSelect, type SingleSelectOption, type SingleSelectProps } from "./single-select";
|
||||
|
||||
type StoryProps = SingleSelectProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & OptionStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/SingleSelect",
|
||||
component: SingleSelect,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete single-select element that combines headline, description, and radio button options. Supports single selection, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of options to choose from",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Selected option ID",
|
||||
table: { category: "State" },
|
||||
},
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["list", "dropdown"],
|
||||
description: "Display variant: 'list' shows radio buttons, 'dropdown' shows a dropdown menu",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for dropdown button when no option is selected",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: function Render(args: StoryProps) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
const [otherValue, setOtherValue] = useState(args.otherValue);
|
||||
const handleOtherValueChange = (v: string) => {
|
||||
setOtherValue(v);
|
||||
args.onOtherValueChange?.(v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<SingleSelect
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={handleOtherValueChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
const defaultOptions: SingleSelectOption[] = [
|
||||
{ id: "option-1", label: "Option 1" },
|
||||
{ id: "option-2", label: "Option 2" },
|
||||
{ id: "option-3", label: "Option 3" },
|
||||
{ id: "option-4", label: "Option 4" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
description: "Select one option",
|
||||
options: defaultOptions,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...optionStylingArgTypes,
|
||||
...inputStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "What is your favorite programming language?",
|
||||
description: "Select the language you use most frequently",
|
||||
options: [
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
{ id: "java", label: "Java" },
|
||||
{ id: "go", label: "Go" },
|
||||
{ id: "rust", label: "Rust" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Select your preferred plan",
|
||||
description: "Please choose one option",
|
||||
options: [
|
||||
{ id: "basic", label: "Basic Plan" },
|
||||
{ id: "pro", label: "Pro Plan" },
|
||||
{ id: "enterprise", label: "Enterprise Plan" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelection: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
description: "Select one option",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Select your preference",
|
||||
description: "Please select an option",
|
||||
options: [
|
||||
{ id: "yes", label: "Yes" },
|
||||
{ id: "no", label: "No" },
|
||||
{ id: "maybe", label: "Maybe" },
|
||||
],
|
||||
errorMessage: "Please select an option",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو خيارك المفضل؟",
|
||||
description: "اختر خيارًا واحدًا",
|
||||
options: [
|
||||
{ id: "opt-1", label: "الخيار الأول" },
|
||||
{ id: "opt-2", label: "الخيار الثاني" },
|
||||
{ id: "opt-3", label: "الخيار الثالث" },
|
||||
{ id: "opt-4", label: "الخيار الرابع" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelection: Story = {
|
||||
args: {
|
||||
headline: "ما هو تفضيلك؟",
|
||||
description: "يرجى اختيار خيار واحد",
|
||||
options: [
|
||||
{ id: "tech", label: "التكنولوجيا" },
|
||||
{ id: "design", label: "التصميم" },
|
||||
{ id: "marketing", label: "التسويق" },
|
||||
{ id: "sales", label: "المبيعات" },
|
||||
],
|
||||
value: "tech",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => {
|
||||
const [value1, setValue1] = React.useState<string | undefined>(undefined);
|
||||
const [value2, setValue2] = React.useState<string>("js");
|
||||
|
||||
return (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<SingleSelect
|
||||
elementId="preference"
|
||||
inputId="preference-input"
|
||||
headline="Which option do you prefer?"
|
||||
description="Select one option"
|
||||
options={defaultOptions}
|
||||
value={value1}
|
||||
onChange={setValue1}
|
||||
/>
|
||||
<SingleSelect
|
||||
elementId="language"
|
||||
inputId="language-input"
|
||||
headline="What is your favorite programming language?"
|
||||
options={[
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
]}
|
||||
value={value2}
|
||||
onChange={setValue2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Dropdown: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
description: "Select one option",
|
||||
options: defaultOptions,
|
||||
variant: "dropdown",
|
||||
placeholder: "Choose an option...",
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithSelection: Story = {
|
||||
args: {
|
||||
headline: "Which option do you prefer?",
|
||||
description: "Select one option",
|
||||
options: defaultOptions,
|
||||
value: "option-2",
|
||||
variant: "dropdown",
|
||||
placeholder: "Choose an option...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string | undefined>(undefined);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<SingleSelect
|
||||
elementId="single-select-other"
|
||||
inputId="single-select-other-input"
|
||||
headline="Which option do you prefer?"
|
||||
description="Select one option"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOptionSelected: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string>("other");
|
||||
const [otherValue, setOtherValue] = React.useState<string>("Custom option");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<SingleSelect
|
||||
elementId="single-select-other-selected"
|
||||
inputId="single-select-other-selected-input"
|
||||
headline="Which option do you prefer?"
|
||||
description="Select one option"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string | undefined>(undefined);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<SingleSelect
|
||||
elementId="single-select-dropdown-other"
|
||||
inputId="single-select-dropdown-other-input"
|
||||
headline="Which option do you prefer?"
|
||||
description="Select one option"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variant="dropdown"
|
||||
placeholder="Choose an option..."
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithContainerStyling: Story = {
|
||||
args: {
|
||||
headline: "Select your preferred option",
|
||||
description: "Each option has a container with custom styling",
|
||||
options: [
|
||||
{ id: "option-1", label: "Option 1" },
|
||||
{ id: "option-2", label: "Option 2" },
|
||||
{ id: "option-3", label: "Option 3" },
|
||||
{ id: "option-4", label: "Option 4" },
|
||||
],
|
||||
value: "option-2",
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const WithContainerStylingAndOther: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string | undefined>(undefined);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<SingleSelect
|
||||
elementId="container-styling-other"
|
||||
inputId="container-styling-other-input"
|
||||
headline="Select an option"
|
||||
description="Options have containers, including the 'Other' option"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
249
packages/survey-ui/src/components/elements/single-select.tsx
Normal file
249
packages/survey-ui/src/components/elements/single-select.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/general/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/general/dropdown-menu";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/general/radio-group";
|
||||
import { useTextDirection } from "@/hooks/use-text-direction";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Option for single-select element
|
||||
*/
|
||||
export interface SingleSelectOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SingleSelectProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the single-select group */
|
||||
inputId: string;
|
||||
/** Array of options to choose from */
|
||||
options: SingleSelectOption[];
|
||||
/** Currently selected option ID */
|
||||
value?: string;
|
||||
/** Callback function called when selection changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display below the options */
|
||||
errorMessage?: string;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the options are disabled */
|
||||
disabled?: boolean;
|
||||
/** Display variant: 'list' shows radio buttons, 'dropdown' shows a dropdown menu */
|
||||
variant?: "list" | "dropdown";
|
||||
/** Placeholder text for dropdown button when no option is selected */
|
||||
placeholder?: string;
|
||||
/** ID for the 'other' option that allows custom input */
|
||||
otherOptionId?: string;
|
||||
/** Label for the 'other' option */
|
||||
otherOptionLabel?: string;
|
||||
/** Placeholder text for the 'other' input field */
|
||||
otherOptionPlaceholder?: string;
|
||||
/** Custom value entered in the 'other' input field */
|
||||
otherValue?: string;
|
||||
/** Callback when the 'other' input value changes */
|
||||
onOtherValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function SingleSelect({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
variant = "list",
|
||||
placeholder = "Select an option...",
|
||||
otherOptionId,
|
||||
otherOptionLabel = "Other",
|
||||
otherOptionPlaceholder = "Please specify",
|
||||
otherValue = "",
|
||||
onOtherValueChange,
|
||||
}: SingleSelectProps): React.JSX.Element {
|
||||
// Ensure value is always a string or undefined
|
||||
const selectedValue = value ?? undefined;
|
||||
const hasOtherOption = Boolean(otherOptionId);
|
||||
const isOtherSelected = hasOtherOption && selectedValue === otherOptionId;
|
||||
|
||||
const handleOtherInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
onOtherValueChange?.(e.target.value);
|
||||
};
|
||||
|
||||
// Shared className for option containers
|
||||
const getOptionContainerClassName = (isSelected: boolean): string =>
|
||||
cn(
|
||||
"relative flex cursor-pointer flex-col border transition-colors outline-none",
|
||||
"rounded-option px-option-x py-option-y",
|
||||
isSelected ? "bg-option-selected-bg border-brand" : "bg-option-bg border-option-border",
|
||||
"focus-within:border-brand focus-within:bg-option-selected-bg",
|
||||
"hover:bg-option-hover-bg",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
);
|
||||
|
||||
// Shared className for option labels
|
||||
const optionLabelClassName = "font-option text-option font-option-weight text-option-label";
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
textContent: [
|
||||
headline,
|
||||
description ?? "",
|
||||
...options.map((opt) => opt.label),
|
||||
...(hasOtherOption ? [otherOptionLabel] : []),
|
||||
],
|
||||
});
|
||||
|
||||
// Get selected option label for dropdown display
|
||||
const selectedOption = options.find((opt) => opt.id === selectedValue);
|
||||
const displayText = isOtherSelected
|
||||
? otherValue || otherOptionLabel
|
||||
: (selectedOption?.label ?? placeholder);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-3">
|
||||
{variant === "dropdown" ? (
|
||||
<>
|
||||
<ElementError errorMessage={errorMessage} dir={detectedDir} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="rounded-input w-full justify-between"
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
aria-label={headline}>
|
||||
<span className="truncate">{displayText}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)]"
|
||||
align="start">
|
||||
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
|
||||
{options.map((option) => {
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
id={optionId}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{option.label}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<DropdownMenuRadioItem
|
||||
value={otherOptionId}
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
disabled={disabled}>
|
||||
<span className={optionLabelClassName}>{otherValue || otherOptionLabel}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
) : null}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isOtherSelected ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={otherValue}
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
dir={detectedDir}
|
||||
className="w-full"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- Auto-focus is intentional for better UX when "other" option is selected
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={selectedValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage}
|
||||
dir={detectedDir}
|
||||
className="w-full gap-0 space-y-2">
|
||||
{options.map((option) => {
|
||||
const optionId = `${inputId}-${option.id}`;
|
||||
const isSelected = selectedValue === option.id;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={optionId}
|
||||
className={cn(getOptionContainerClassName(isSelected), isSelected && "z-10")}>
|
||||
<span className="flex items-center text-sm">
|
||||
<RadioGroupItem value={option.id} id={optionId} disabled={disabled} />
|
||||
<span className={cn("ml-3 mr-3 grow", optionLabelClassName)}>{option.label}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{hasOtherOption && otherOptionId ? (
|
||||
<label
|
||||
htmlFor={`${inputId}-${otherOptionId}`}
|
||||
className={cn(getOptionContainerClassName(isOtherSelected), isOtherSelected && "z-10")}>
|
||||
<span className="flex items-center text-sm">
|
||||
<RadioGroupItem
|
||||
value={otherOptionId}
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={cn("ml-3 mr-3 grow", optionLabelClassName)}>{otherOptionLabel}</span>
|
||||
</span>
|
||||
{isOtherSelected ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={otherValue}
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
dir={detectedDir}
|
||||
className="mt-2 w-full"
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- Auto-focus is intentional for better UX when "other" option is selected
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
) : null}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { SingleSelect };
|
||||
export type { SingleSelectProps };
|
||||
@@ -3,7 +3,7 @@ import { TriangleAlertIcon } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./alert";
|
||||
|
||||
const meta: Meta<typeof Alert> = {
|
||||
title: "UI-package/Alert",
|
||||
title: "UI-package/General/Alert",
|
||||
component: Alert,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
@@ -7,6 +7,8 @@ interface StylingOptions {
|
||||
buttonHeight: string;
|
||||
buttonWidth: string;
|
||||
buttonFontSize: string;
|
||||
buttonFontFamily: string;
|
||||
buttonFontWeight: string;
|
||||
buttonBorderRadius: string;
|
||||
buttonBgColor: string;
|
||||
buttonTextColor: string;
|
||||
@@ -18,7 +20,7 @@ type ButtonProps = React.ComponentProps<typeof Button>;
|
||||
type StoryProps = ButtonProps & StylingOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Button",
|
||||
title: "UI-package/General/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
@@ -27,7 +29,7 @@ const meta: Meta<StoryProps> = {
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"],
|
||||
description: "Visual style variant of the button",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
@@ -61,6 +63,8 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
buttonHeight,
|
||||
buttonWidth,
|
||||
buttonFontSize,
|
||||
buttonFontFamily,
|
||||
buttonFontWeight,
|
||||
buttonBorderRadius,
|
||||
buttonBgColor,
|
||||
buttonTextColor,
|
||||
@@ -68,16 +72,18 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
buttonPaddingY,
|
||||
} = args;
|
||||
|
||||
const cssVarStyle = {
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||
"--fb-button-height": buttonHeight,
|
||||
"--fb-button-width": buttonWidth,
|
||||
"--fb-button-font-size": buttonFontSize,
|
||||
"--fb-button-font-family": buttonFontFamily,
|
||||
"--fb-button-font-weight": buttonFontWeight,
|
||||
"--fb-button-border-radius": buttonBorderRadius,
|
||||
"--fb-button-bg-color": buttonBgColor,
|
||||
"--fb-button-text-color": buttonTextColor,
|
||||
"--fb-button-padding-x": buttonPaddingX,
|
||||
"--fb-button-padding-y": buttonPaddingY,
|
||||
} as React.CSSProperties;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cssVarStyle}>
|
||||
@@ -88,16 +94,8 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
variant: "custom",
|
||||
children: "Custom Button",
|
||||
// Default styling values
|
||||
buttonHeight: "40px",
|
||||
buttonWidth: "auto",
|
||||
buttonFontSize: "14px",
|
||||
buttonBorderRadius: "0.5rem",
|
||||
buttonBgColor: "#3b82f6",
|
||||
buttonTextColor: "#ffffff",
|
||||
buttonPaddingX: "16px",
|
||||
buttonPaddingY: "8px",
|
||||
},
|
||||
argTypes: {
|
||||
// Button Styling (CSS Variables) - Only for this story
|
||||
@@ -105,56 +103,60 @@ export const StylingPlayground: Story = {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
defaultValue: { summary: "40px" },
|
||||
},
|
||||
},
|
||||
buttonWidth: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
defaultValue: { summary: "auto" },
|
||||
},
|
||||
},
|
||||
buttonFontSize: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
defaultValue: { summary: "14px" },
|
||||
},
|
||||
},
|
||||
buttonFontFamily: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonFontWeight: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
},
|
||||
},
|
||||
buttonBorderRadius: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
defaultValue: { summary: "var(--fb-border-radius)" },
|
||||
},
|
||||
},
|
||||
buttonBgColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
defaultValue: { summary: "var(--fb-brand-color)" },
|
||||
},
|
||||
},
|
||||
buttonTextColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
defaultValue: { summary: "var(--fb-brand-text-color)" },
|
||||
},
|
||||
},
|
||||
buttonPaddingX: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
defaultValue: { summary: "16px" },
|
||||
},
|
||||
},
|
||||
buttonPaddingY: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Button Styling",
|
||||
defaultValue: { summary: "8px" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -223,6 +225,13 @@ export const Icon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Custom: Story = {
|
||||
args: {
|
||||
variant: "custom",
|
||||
children: "Custom Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
@@ -16,6 +16,7 @@ const buttonVariants = cva(
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
custom: "button-custom",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
@@ -31,20 +32,6 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
// Default styles driven by CSS variables
|
||||
export const cssVarStyles: React.CSSProperties = {
|
||||
height: "var(--fb-button-height)",
|
||||
width: "var(--fb-button-width)",
|
||||
fontSize: "var(--fb-button-font-size)",
|
||||
borderRadius: "var(--fb-button-border-radius)",
|
||||
backgroundColor: "var(--fb-button-bg-color)",
|
||||
color: "var(--fb-button-text-color)",
|
||||
paddingLeft: "var(--fb-button-padding-x)",
|
||||
paddingRight: "var(--fb-button-padding-x)",
|
||||
paddingTop: "var(--fb-button-padding-y)",
|
||||
paddingBottom: "var(--fb-button-padding-y)",
|
||||
};
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
@@ -62,7 +49,6 @@ function Button({
|
||||
data-slot="button"
|
||||
aria-label={props["aria-label"]}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
style={cssVarStyles}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
214
packages/survey-ui/src/components/general/calendar.tsx
Normal file
214
packages/survey-ui/src/components/general/calendar.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { type Locale, format } from "date-fns";
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { type DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import { Button, buttonVariants } from "@/components/general/button";
|
||||
import { getDateFnsLocale } from "@/lib/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Extracted components to avoid defining during render
|
||||
function CalendarRoot({
|
||||
className,
|
||||
rootRef,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
rootRef?: React.Ref<HTMLDivElement>;
|
||||
children?: React.ReactNode;
|
||||
}): React.JSX.Element {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function CalendarChevron({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
orientation?: "left" | "right" | "up" | "down";
|
||||
}): React.JSX.Element {
|
||||
if (orientation === "left") {
|
||||
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CalendarWeekNumber({ children, ...props }: { children?: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex h-[--cell-size] w-[--cell-size] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
locale?: Locale | string;
|
||||
}): React.JSX.Element {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
// Resolve locale to Locale object if string is provided
|
||||
const resolvedLocale = React.useMemo(() => {
|
||||
if (!locale) return undefined;
|
||||
if (typeof locale === "string") {
|
||||
return getDateFnsLocale(locale);
|
||||
}
|
||||
return locale;
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={resolvedLocale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => {
|
||||
if (resolvedLocale) {
|
||||
return format(date, "MMM", { locale: resolvedLocale });
|
||||
}
|
||||
return date.toLocaleString("default", { month: "short" });
|
||||
},
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-[--cell-size] w-full px-[--cell-size]",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-[--cell-size] gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input-border shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none text-[var(--fb-input-color)] opacity-70",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn("select-none w-[--cell-size]", defaultClassNames.week_number_header),
|
||||
week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-[var(--fb-input-color)] opacity-70 aria-selected:text-[var(--fb-input-color)] opacity-70",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: CalendarRoot,
|
||||
Chevron: CalendarChevron,
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: CalendarWeekNumber,
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>): React.JSX.Element {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end ? !modifiers.range_middle : null
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -3,7 +3,7 @@ import { Checkbox } from "./checkbox";
|
||||
import { Label } from "./label";
|
||||
|
||||
const meta: Meta<typeof Checkbox> = {
|
||||
title: "UI-package/Checkbox",
|
||||
title: "UI-package/General/Checkbox",
|
||||
component: Checkbox,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -13,7 +13,7 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer size-4 shrink-0 rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"border-input-border dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs text-input-text bg-input-bg peer size-4 shrink-0 rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
@@ -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 @@
|
||||
"use client";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
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" }: ElementErrorProps): React.JSX.Element | null {
|
||||
if (!errorMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Error indicator bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-destructive absolute bottom-0 top-0 w-[4px]",
|
||||
dir === "rtl" ? "right-[-12px]" : "left-[-12px]"
|
||||
)}
|
||||
/>
|
||||
{/* Error message - shown at top */}
|
||||
<div className="text-destructive flex items-center gap-1 text-sm" dir={dir}>
|
||||
<AlertCircle className="size-4" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElementError };
|
||||
export type { ElementErrorProps };
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
55
packages/survey-ui/src/components/general/element-header.tsx
Normal file
55
packages/survey-ui/src/components/general/element-header.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { ElementMedia } from "@/components/general/element-media";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ElementHeaderProps extends React.ComponentProps<"div"> {
|
||||
headline: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
htmlFor?: string;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
imageAltText?: string;
|
||||
}
|
||||
|
||||
function ElementHeader({
|
||||
headline,
|
||||
description,
|
||||
required = false,
|
||||
htmlFor,
|
||||
className,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
imageAltText,
|
||||
...props
|
||||
}: ElementHeaderProps): React.JSX.Element {
|
||||
const isMediaAvailable = imageUrl ?? videoUrl;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
{/* Media (Image or Video) */}
|
||||
{isMediaAvailable ? (
|
||||
<ElementMedia imgUrl={imageUrl} videoUrl={videoUrl} altText={imageAltText} />
|
||||
) : null}
|
||||
|
||||
{/* Headline */}
|
||||
<div className="flex">
|
||||
<Label htmlFor={htmlFor} variant="headline">
|
||||
{headline}
|
||||
</Label>
|
||||
{required ? <span>*</span> : null}
|
||||
</div>
|
||||
|
||||
{/* Description/Subheader */}
|
||||
{description ? (
|
||||
<Label htmlFor={htmlFor} variant="description">
|
||||
{description}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElementHeader };
|
||||
export type { ElementHeaderProps };
|
||||
100
packages/survey-ui/src/components/general/element-media.tsx
Normal file
100
packages/survey-ui/src/components/general/element-media.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Expand } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
|
||||
|
||||
// Function to add extra params to videoUrls in order to reduce video controls
|
||||
const getVideoUrlWithParams = (videoUrl: string): string | undefined => {
|
||||
// First convert to embed URL
|
||||
const embedUrl = convertToEmbedUrl(videoUrl);
|
||||
if (!embedUrl) return undefined;
|
||||
|
||||
const isYoutubeVideo = checkForYoutubeUrl(videoUrl);
|
||||
const isVimeoUrl = checkForVimeoUrl(videoUrl);
|
||||
const isLoomUrl = checkForLoomUrl(videoUrl);
|
||||
|
||||
if (isYoutubeVideo) {
|
||||
// For YouTube, add parameters to embed URL
|
||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
||||
return `${embedUrl}${separator}controls=0`;
|
||||
} else if (isVimeoUrl) {
|
||||
// For Vimeo, add parameters to embed URL
|
||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
||||
return `${embedUrl}${separator}title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false`;
|
||||
} else if (isLoomUrl) {
|
||||
// For Loom, add parameters to embed URL
|
||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
||||
return `${embedUrl}${separator}hide_share=true&hideEmbedTopBar=true&hide_title=true`;
|
||||
}
|
||||
return embedUrl;
|
||||
};
|
||||
|
||||
interface ElementMediaProps {
|
||||
imgUrl?: string;
|
||||
videoUrl?: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: ElementMediaProps): React.JSX.Element {
|
||||
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
if (!imgUrl && !videoUrl) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/image relative mb-6 block min-h-40 rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-auto flex h-full w-full animate-pulse items-center justify-center rounded-md bg-slate-200" />
|
||||
) : null}
|
||||
{imgUrl ? (
|
||||
<img
|
||||
key={imgUrl}
|
||||
src={imgUrl}
|
||||
alt={altText}
|
||||
className={cn("mx-auto max-h-[40dvh] rounded-md object-contain", isLoading ? "opacity-0" : "")}
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{videoUrlWithParams ? (
|
||||
<div className="relative">
|
||||
<div className="rounded-md bg-black">
|
||||
<iframe
|
||||
src={videoUrlWithParams}
|
||||
title="Question video"
|
||||
frameBorder="0"
|
||||
className={cn("aspect-video w-full rounded-md", isLoading ? "opacity-0" : "")}
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<a
|
||||
href={imgUrl ? imgUrl : convertToEmbedUrl(videoUrl ?? "")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open in new tab"
|
||||
className="absolute bottom-2 right-2 flex items-center gap-2 rounded-md bg-slate-800 bg-opacity-40 p-1.5 text-white opacity-0 backdrop-blur-lg transition duration-300 ease-in-out hover:bg-opacity-65 group-hover/image:opacity-100">
|
||||
{imgUrl ? <Download size={20} /> : <Expand size={20} />}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ElementMedia };
|
||||
export type { ElementMediaProps };
|
||||
@@ -17,12 +17,13 @@ interface StylingOptions {
|
||||
inputPaddingX: string;
|
||||
inputPaddingY: string;
|
||||
inputShadow: string;
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
type StoryProps = InputProps & Partial<StylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Input",
|
||||
title: "UI-package/General/Input",
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
@@ -75,6 +76,7 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
inputPaddingX,
|
||||
inputPaddingY,
|
||||
inputShadow,
|
||||
brandColor,
|
||||
} = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||
@@ -91,6 +93,7 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
"--fb-input-padding-x": inputPaddingX,
|
||||
"--fb-input-padding-y": inputPaddingY,
|
||||
"--fb-input-shadow": inputShadow,
|
||||
"--fb-survey-brand-color": brandColor,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -103,20 +106,6 @@ const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
placeholder: "Enter text...",
|
||||
// Default styling values
|
||||
inputWidth: "400px",
|
||||
inputHeight: "2.5rem",
|
||||
inputBgColor: "#ffffff",
|
||||
inputBorderColor: "#e2e8f0",
|
||||
inputBorderRadius: "0.5rem",
|
||||
inputFontFamily: "system-ui, sans-serif",
|
||||
inputFontSize: "0.875rem",
|
||||
inputFontWeight: "400",
|
||||
inputColor: "#1e293b",
|
||||
inputPlaceholderColor: "#94a3b8",
|
||||
inputPaddingX: "0.75rem",
|
||||
inputPaddingY: "0.5rem",
|
||||
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
},
|
||||
argTypes: {
|
||||
// Input Styling (CSS Variables) - Only for this story
|
||||
@@ -211,6 +200,13 @@ export const StylingPlayground: Story = {
|
||||
defaultValue: { summary: "0 1px 2px 0 rgb(0 0 0 / 0.05)" },
|
||||
},
|
||||
},
|
||||
brandColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Input Styling",
|
||||
defaultValue: { summary: "var(--fb-survey-brand-color)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withCSSVariables],
|
||||
};
|
||||
@@ -271,34 +267,6 @@ export const DisabledWithValue: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const FileUpload: Story = {
|
||||
args: {
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
|
||||
export const FileUploadWithRTL: Story = {
|
||||
args: {
|
||||
type: "file",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const FileUploadWithError: Story = {
|
||||
args: {
|
||||
type: "file",
|
||||
errorMessage: "Please upload a valid file",
|
||||
},
|
||||
};
|
||||
|
||||
export const FileUploadWithErrorAndRTL: Story = {
|
||||
args: {
|
||||
type: "file",
|
||||
errorMessage: "Please upload a valid file",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
dir: "rtl",
|
||||
60
packages/survey-ui/src/components/general/input.tsx
Normal file
60
packages/survey-ui/src/components/general/input.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InputProps extends React.ComponentProps<"input"> {
|
||||
/** Text direction for RTL language support */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Error message to display above the input */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, errorMessage, dir, ...props }, ref): React.JSX.Element => {
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
dir={dir}
|
||||
data-slot="input"
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn(
|
||||
// Layout and behavior
|
||||
"flex min-w-0 border outline-none transition-[color,box-shadow]",
|
||||
// Customizable styles via CSS variables (using Tailwind theme extensions)
|
||||
"w-input h-input",
|
||||
"bg-input-bg border-input-border rounded-input",
|
||||
"font-input font-input-weight",
|
||||
"text-input",
|
||||
"px-input-x py-input-y",
|
||||
"shadow-input",
|
||||
// Placeholder styling
|
||||
"[&::placeholder]:opacity-input-placeholder",
|
||||
"placeholder:text-input-placeholder",
|
||||
// Selection styling
|
||||
"selection:bg-primary selection:text-primary-foreground",
|
||||
// File input specifics
|
||||
"file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
// Focus ring
|
||||
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
|
||||
// Error state ring
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
// Disabled state
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
export type { InputProps };
|
||||
@@ -34,7 +34,7 @@ type StoryProps = LabelProps &
|
||||
Partial<HeadlineStylingOptions & DescriptionStylingOptions & DefaultStylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Label",
|
||||
title: "UI-package/General/Label",
|
||||
component: Label,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
@@ -78,11 +78,11 @@ const withHeadlineCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
const { headlineFontFamily, headlineFontWeight, headlineFontSize, headlineColor, headlineOpacity } = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||
"--fb-question-headline-font-family": headlineFontFamily ?? undefined,
|
||||
"--fb-question-headline-font-weight": headlineFontWeight ?? undefined,
|
||||
"--fb-question-headline-font-size": headlineFontSize ?? undefined,
|
||||
"--fb-question-headline-color": headlineColor ?? undefined,
|
||||
"--fb-question-headline-opacity": headlineOpacity ?? undefined,
|
||||
"--fb-element-headline-font-family": headlineFontFamily ?? undefined,
|
||||
"--fb-element-headline-font-weight": headlineFontWeight ?? undefined,
|
||||
"--fb-element-headline-font-size": headlineFontSize ?? undefined,
|
||||
"--fb-element-headline-color": headlineColor ?? undefined,
|
||||
"--fb-element-headline-opacity": headlineOpacity ?? undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -105,11 +105,11 @@ const withDescriptionCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
} = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||
"--fb-question-description-font-family": descriptionFontFamily ?? undefined,
|
||||
"--fb-question-description-font-weight": descriptionFontWeight ?? undefined,
|
||||
"--fb-question-description-font-size": descriptionFontSize ?? undefined,
|
||||
"--fb-question-description-color": descriptionColor ?? undefined,
|
||||
"--fb-question-description-opacity": descriptionOpacity ?? undefined,
|
||||
"--fb-element-description-font-family": descriptionFontFamily ?? undefined,
|
||||
"--fb-element-description-font-weight": descriptionFontWeight ?? undefined,
|
||||
"--fb-element-description-font-size": descriptionFontSize ?? undefined,
|
||||
"--fb-element-description-color": descriptionColor ?? undefined,
|
||||
"--fb-element-description-opacity": descriptionOpacity ?? undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
94
packages/survey-ui/src/components/general/label.tsx
Normal file
94
packages/survey-ui/src/components/general/label.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LabelProps extends React.ComponentProps<typeof LabelPrimitive.Root> {
|
||||
/** Label variant for different styling contexts */
|
||||
variant?: "default" | "headline" | "description";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string contains valid HTML markup
|
||||
* @param str - The input string to test
|
||||
* @returns true if the string contains valid HTML elements, false otherwise
|
||||
*/
|
||||
const isValidHTML = (str: string): boolean => {
|
||||
if (!str) return false;
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(str, "text/html");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
if (errorNode) return false;
|
||||
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips inline style attributes to prevent CSP violations
|
||||
* @param html - The HTML string to clean
|
||||
* @returns HTML string without inline style attributes
|
||||
*/
|
||||
const stripInlineStyles = (html: string): string => {
|
||||
return html.replace(/\s*style\s*=\s*["'][^"']*["']/gi, "");
|
||||
};
|
||||
|
||||
function Label({ className, variant = "default", children, ...props }: LabelProps): React.JSX.Element {
|
||||
// Check if children is a string and contains HTML
|
||||
const childrenString = typeof children === "string" ? children : null;
|
||||
const strippedContent = childrenString ? stripInlineStyles(childrenString) : "";
|
||||
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
|
||||
const safeHtml =
|
||||
isHtml && strippedContent
|
||||
? DOMPurify.sanitize(strippedContent, {
|
||||
ADD_ATTR: ["target"],
|
||||
FORBID_ATTR: ["style"],
|
||||
})
|
||||
: "";
|
||||
|
||||
// Determine variant class
|
||||
let variantClass = "label-default";
|
||||
if (variant === "headline") {
|
||||
variantClass = "label-headline";
|
||||
} else if (variant === "description") {
|
||||
variantClass = "label-description";
|
||||
}
|
||||
|
||||
// If HTML, render with dangerouslySetInnerHTML, otherwise render normally
|
||||
if (isHtml && safeHtml) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"flex select-none items-center gap-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||
variantClass,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"flex select-none items-center gap-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||
variantClass,
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
</LabelPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
export type { LabelProps };
|
||||
41
packages/survey-ui/src/components/general/popover.tsx
Normal file
41
packages/survey-ui/src/components/general/popover.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
152
packages/survey-ui/src/components/general/progress.stories.tsx
Normal file
152
packages/survey-ui/src/components/general/progress.stories.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Progress, type ProgressProps } from "./progress";
|
||||
|
||||
// Styling options for the StylingPlayground story
|
||||
interface StylingOptions {
|
||||
trackHeight: string;
|
||||
trackBgColor: string;
|
||||
trackBorderRadius: string;
|
||||
indicatorBgColor: string;
|
||||
indicatorBorderRadius: string;
|
||||
}
|
||||
|
||||
type StoryProps = ProgressProps & Partial<StylingOptions>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/General/Progress",
|
||||
component: Progress,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
value: {
|
||||
control: { type: "range", min: 0, max: 100, step: 1 },
|
||||
description: "Progress value (0-100)",
|
||||
table: { category: "Component Props" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Storybook's Decorator type doesn't properly infer args type
|
||||
const args = context.args as StoryProps;
|
||||
const { trackHeight, trackBgColor, trackBorderRadius, indicatorBgColor, indicatorBorderRadius } = args;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||
"--fb-progress-track-height": trackHeight,
|
||||
"--fb-progress-track-bg-color": trackBgColor,
|
||||
"--fb-progress-track-border-radius": trackBorderRadius,
|
||||
"--fb-progress-indicator-bg-color": indicatorBgColor,
|
||||
"--fb-progress-indicator-border-radius": indicatorBorderRadius,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={cssVarStyle}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args: StoryProps) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 50,
|
||||
},
|
||||
};
|
||||
|
||||
export const Zero: Story = {
|
||||
render: (args: StoryProps) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const Half: Story = {
|
||||
render: (args: StoryProps) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 50,
|
||||
},
|
||||
};
|
||||
|
||||
export const Complete: Story = {
|
||||
render: (args: StoryProps) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 100,
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyles: Story = {
|
||||
render: (args: StoryProps) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 75,
|
||||
trackHeight: "1.25rem",
|
||||
trackBgColor: "hsl(0 0% 0% / 0.3)",
|
||||
trackBorderRadius: "0.75rem",
|
||||
indicatorBgColor: "hsl(142 76% 36%)",
|
||||
indicatorBorderRadius: "0.75rem",
|
||||
},
|
||||
argTypes: {
|
||||
trackHeight: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Progress Styling",
|
||||
defaultValue: { summary: "0.5rem" },
|
||||
},
|
||||
},
|
||||
trackBgColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Progress Styling",
|
||||
defaultValue: { summary: "hsl(222.2 47.4% 11.2% / 0.2)" },
|
||||
},
|
||||
},
|
||||
trackBorderRadius: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Progress Styling",
|
||||
defaultValue: { summary: "var(--radius)" },
|
||||
},
|
||||
},
|
||||
indicatorBgColor: {
|
||||
control: "color",
|
||||
table: {
|
||||
category: "Progress Styling",
|
||||
defaultValue: { summary: "hsl(222.2 47.4% 11.2%)" },
|
||||
},
|
||||
},
|
||||
indicatorBorderRadius: {
|
||||
control: "text",
|
||||
table: {
|
||||
category: "Progress Styling",
|
||||
defaultValue: { summary: "var(--radius)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withCSSVariables],
|
||||
};
|
||||
30
packages/survey-ui/src/components/general/progress.tsx
Normal file
30
packages/survey-ui/src/components/general/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProgressProps extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
value?: number;
|
||||
}
|
||||
|
||||
function Progress({ className, value, ...props }: ProgressProps): React.JSX.Element {
|
||||
const progressValue: number = typeof value === "number" ? value : 0;
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
value={progressValue}
|
||||
className={cn("progress-track relative w-full overflow-hidden", className)}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="progress-indicator h-full w-full flex-1 transition-all"
|
||||
style={{
|
||||
transform: `translateX(-${String(100 - progressValue)}%)`,
|
||||
}}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
@@ -3,7 +3,7 @@ import { Label } from "./label";
|
||||
import { RadioGroup, RadioGroupItem } from "./radio-group";
|
||||
|
||||
const meta: Meta<typeof RadioGroup> = {
|
||||
title: "UI-package/RadioGroup",
|
||||
title: "UI-package/General/RadioGroup",
|
||||
component: RadioGroup,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
@@ -144,7 +144,7 @@ export const PaymentMethod: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const SurveyQuestion: Story = {
|
||||
export const SurveyElement: Story = {
|
||||
render: () => (
|
||||
<div className="w-[400px] space-y-4">
|
||||
<div>
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { AlertCircle, CircleIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
@@ -15,9 +15,9 @@ function RadioGroup({
|
||||
dir?: "ltr" | "rtl";
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex gap-2" dir={dir}>
|
||||
<div className="flex w-full gap-2" dir={dir}>
|
||||
{errorMessage ? <div className="bg-destructive min-h-full w-1" /> : null}
|
||||
<div className="space-y-2">
|
||||
<div className="w-full space-y-2">
|
||||
{errorMessage ? (
|
||||
<div className="text-destructive flex items-center gap-1 text-sm">
|
||||
<AlertCircle className="size-4" />
|
||||
@@ -28,7 +28,7 @@ function RadioGroup({
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
data-slot="radio-group"
|
||||
dir={dir}
|
||||
className={cn("grid gap-3", className)}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
@@ -44,14 +44,17 @@ function RadioGroupItem({
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-primary text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 shadow-xs aspect-square size-4 shrink-0 rounded-full border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"border-input-border text-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 shadow-xs aspect-square size-4 shrink-0 rounded-full border bg-white outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center">
|
||||
<CircleIcon className="fill-primary absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2" />
|
||||
<CircleIcon
|
||||
fill="currentColor"
|
||||
className="absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
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" />];
|
||||
@@ -3,7 +3,7 @@ import { Label } from "./label";
|
||||
import { Textarea } from "./textarea";
|
||||
|
||||
const meta: Meta<typeof Textarea> = {
|
||||
title: "UI-package/Textarea",
|
||||
title: "UI-package/General/Textarea",
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
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 * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextareaProps = React.ComponentProps<"textarea"> & {
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
function Textarea({ className, errorMessage, dir = "auto", ...props }: TextareaProps): React.JSX.Element {
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
dir={dir}
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn(
|
||||
"w-input h-input bg-input-bg border-input-border rounded-input font-input font-input-weight text-input-text px-input-x py-input-y shadow-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content text-input flex min-h-16 border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,72 +0,0 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface InputProps extends React.ComponentProps<"input"> {
|
||||
/** Text direction for RTL language support */
|
||||
dir?: "ltr" | "rtl";
|
||||
/** Error message to display above the input */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function Input({ className, type, errorMessage, dir, ...props }: InputProps): React.JSX.Element {
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
// Default styles driven by CSS variables
|
||||
const cssVarStyles: React.CSSProperties = {
|
||||
width: "var(--fb-input-width)",
|
||||
height: "var(--fb-input-height)",
|
||||
backgroundColor: "var(--fb-input-bg-color)",
|
||||
borderColor: "var(--fb-input-border-color)",
|
||||
borderRadius: "var(--fb-input-border-radius)",
|
||||
fontFamily: "var(--fb-input-font-family)",
|
||||
fontSize: "var(--fb-input-font-size)",
|
||||
fontWeight: "var(--fb-input-font-weight)" as React.CSSProperties["fontWeight"],
|
||||
color: "var(--fb-input-color)",
|
||||
paddingLeft: "var(--fb-input-padding-x)",
|
||||
paddingRight: "var(--fb-input-padding-x)",
|
||||
paddingTop: "var(--fb-input-padding-y)",
|
||||
paddingBottom: "var(--fb-input-padding-y)",
|
||||
boxShadow: "var(--fb-input-shadow)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{errorMessage ? (
|
||||
<div className="text-destructive flex items-center gap-1 text-sm" dir={dir}>
|
||||
<AlertCircle className="size-4" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
type={type}
|
||||
dir={dir}
|
||||
style={cssVarStyles}
|
||||
data-slot="input"
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn(
|
||||
// Layout and behavior (Tailwind)
|
||||
"flex min-w-0 border outline-none transition-[color,box-shadow]",
|
||||
// Placeholder styling via CSS variable
|
||||
"[&::placeholder]:opacity-[var(--fb-input-placeholder-opacity)]",
|
||||
"placeholder:[color:var(--fb-input-placeholder-color)]",
|
||||
// Selection styling
|
||||
"selection:bg-primary selection:text-primary-foreground",
|
||||
// File input specifics
|
||||
"file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
// Focus ring
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
// Error state ring
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
// Disabled state
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
export type { InputProps };
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface LabelProps extends React.ComponentProps<typeof LabelPrimitive.Root> {
|
||||
/** Label variant for different styling contexts */
|
||||
variant?: "default" | "headline" | "description";
|
||||
}
|
||||
|
||||
function Label({ className, variant = "default", ...props }: LabelProps): React.JSX.Element {
|
||||
// Default styles driven by CSS variables based on variant
|
||||
const getCssVarStyles = (): React.CSSProperties => {
|
||||
if (variant === "headline") {
|
||||
return {
|
||||
fontFamily: "var(--fb-question-headline-font-family)",
|
||||
fontWeight: "var(--fb-question-headline-font-weight)" as React.CSSProperties["fontWeight"],
|
||||
fontSize: "var(--fb-question-headline-font-size)",
|
||||
color: "var(--fb-question-headline-color)",
|
||||
opacity: "var(--fb-question-headline-opacity)",
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "description") {
|
||||
return {
|
||||
fontFamily: "var(--fb-question-description-font-family)",
|
||||
fontWeight: "var(--fb-question-description-font-weight)" as React.CSSProperties["fontWeight"],
|
||||
fontSize: "var(--fb-question-description-font-size)",
|
||||
color: "var(--fb-question-description-color)",
|
||||
opacity: "var(--fb-question-description-opacity)",
|
||||
};
|
||||
}
|
||||
|
||||
// Default variant styles
|
||||
return {
|
||||
fontFamily: "var(--fb-label-font-family)",
|
||||
fontWeight: "var(--fb-label-font-weight)" as React.CSSProperties["fontWeight"],
|
||||
fontSize: "var(--fb-label-font-size)",
|
||||
color: "var(--fb-label-color)",
|
||||
opacity: "var(--fb-label-opacity)",
|
||||
};
|
||||
};
|
||||
|
||||
const cssVarStyles = getCssVarStyles();
|
||||
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||
className
|
||||
)}
|
||||
style={cssVarStyles}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
export type { LabelProps };
|
||||
@@ -1,90 +0,0 @@
|
||||
import { type Meta, type StoryObj } from "@storybook/react";
|
||||
import { Progress } from "./progress";
|
||||
|
||||
const meta: Meta<typeof Progress> = {
|
||||
title: "UI-package/Progress",
|
||||
component: Progress,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
value: {
|
||||
control: { type: "range", min: 0, max: 100, step: 1 },
|
||||
description: "Progress value (0-100)",
|
||||
},
|
||||
indicatorStyle: {
|
||||
control: { type: "object" },
|
||||
description: "Style for the progress indicator",
|
||||
},
|
||||
trackStyle: {
|
||||
control: { type: "object" },
|
||||
description: "Style for the progress track",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Progress>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args: React.ComponentProps<typeof Progress>) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 50,
|
||||
},
|
||||
};
|
||||
|
||||
export const Zero: Story = {
|
||||
render: (args: React.ComponentProps<typeof Progress>) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const Half: Story = {
|
||||
render: (args: React.ComponentProps<typeof Progress>) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 50,
|
||||
},
|
||||
};
|
||||
|
||||
export const Complete: Story = {
|
||||
render: (args: React.ComponentProps<typeof Progress>) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 100,
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyles: Story = {
|
||||
render: (args: React.ComponentProps<typeof Progress>) => (
|
||||
<div className="w-64">
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
value: 75,
|
||||
indicatorStyle: {
|
||||
backgroundColor: "green",
|
||||
},
|
||||
trackStyle: {
|
||||
backgroundColor: "black",
|
||||
height: "20px",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface ProgressProps extends Omit<React.ComponentProps<"div">, "children"> {
|
||||
/** Progress value (0-100) */
|
||||
value?: number;
|
||||
/** Custom inline styles for the progress indicator */
|
||||
indicatorStyle?: React.CSSProperties;
|
||||
/** Custom inline styles for the progress track */
|
||||
trackStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
indicatorStyle,
|
||||
trackStyle,
|
||||
...props
|
||||
}: ProgressProps): React.JSX.Element {
|
||||
const progressValue: number = typeof value === "number" ? value : 0;
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
|
||||
style={trackStyle}
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${String(100 - progressValue)}%)`, ...indicatorStyle }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
@@ -1,37 +0,0 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type TextareaProps = React.ComponentProps<"textarea"> & {
|
||||
dir?: "ltr" | "rtl";
|
||||
errorMessage?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Textarea({ className, errorMessage, dir, style, ...props }: TextareaProps): React.JSX.Element {
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{errorMessage ? (
|
||||
<div className="text-destructive flex items-center gap-1 text-sm" dir={dir}>
|
||||
<AlertCircle className="size-4" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
dir={dir}
|
||||
style={style}
|
||||
aria-invalid={hasError || undefined}
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
@@ -1,2 +1,2 @@
|
||||
// Export custom hooks here
|
||||
export {};
|
||||
export { useTextDirection, type UseTextDirectionOptions } from "@/hooks/use-text-direction";
|
||||
|
||||
103
packages/survey-ui/src/hooks/use-text-direction.ts
Normal file
103
packages/survey-ui/src/hooks/use-text-direction.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Detects whether a string contains RTL (right-to-left) or LTR (left-to-right) characters
|
||||
* Returns 'rtl' if RTL characters are found, 'ltr' if LTR characters are found, 'neutral' if no directional characters
|
||||
*
|
||||
* @param text - The text to analyze
|
||||
* @returns 'rtl' | 'ltr' | 'neutral'
|
||||
*
|
||||
* @example
|
||||
* detectTextDirection("Hello world") // returns 'ltr'
|
||||
* detectTextDirection("مرحبا بالعالم") // returns 'rtl'
|
||||
* detectTextDirection("123 !#") // returns 'neutral'
|
||||
*/
|
||||
function detectTextDirection(text: string): "rtl" | "ltr" | "neutral" {
|
||||
if (!text || text.trim().length === 0) {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
// Unicode ranges for RTL characters:
|
||||
// Hebrew: \u0590-\u05FF
|
||||
// Arabic: \u0600-\u06FF
|
||||
// Arabic Supplement: \u0750-\u077F
|
||||
// Arabic Extended-A: \u0800-\u083F
|
||||
// Arabic Extended-B: \u0840-\u085F
|
||||
// Syriac: \u0700-\u074F
|
||||
// Thaana: \u0780-\u07BF
|
||||
// Nko: \u07C0-\u07FF
|
||||
const rtlPattern =
|
||||
/[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u0800-\u083F\u0840-\u085F\u07C0-\u07FF]/g;
|
||||
|
||||
// Unicode ranges for LTR characters:
|
||||
// Latin: \u0041-\u005A (A-Z), \u0061-\u007A (a-z)
|
||||
// Latin Extended: \u0100-\u017F
|
||||
// Latin Extended-A: \u0100-\u017F
|
||||
// Latin Extended-B: \u0180-\u024F
|
||||
const ltrPattern = /[A-Za-z\u0100-\u024F]/g;
|
||||
|
||||
const rtlMatch = text.match(rtlPattern);
|
||||
const ltrMatch = text.match(ltrPattern);
|
||||
|
||||
// If both RTL and LTR characters are present, RTL takes precedence
|
||||
if (rtlMatch && ltrMatch) {
|
||||
return rtlMatch.length >= ltrMatch.length ? "rtl" : "ltr";
|
||||
}
|
||||
|
||||
if (rtlMatch) {
|
||||
return "rtl";
|
||||
}
|
||||
|
||||
if (ltrMatch) {
|
||||
return "ltr";
|
||||
}
|
||||
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export interface UseTextDirectionOptions {
|
||||
/** Explicit direction prop (takes precedence over detection) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Text content to analyze for direction detection */
|
||||
textContent?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect and determine text direction for form elements
|
||||
*
|
||||
* @param options - Configuration options containing dir and textContent
|
||||
* @returns The detected or provided text direction ("ltr" | "rtl" | undefined)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const dir = useTextDirection({
|
||||
* dir: "auto",
|
||||
* textContent: [headline, description, placeholder]
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useTextDirection({ dir, textContent }: UseTextDirectionOptions): "ltr" | "rtl" | undefined {
|
||||
return useMemo(() => {
|
||||
// If explicit direction is provided and not "auto", use it
|
||||
if (dir && dir !== "auto") {
|
||||
return dir;
|
||||
}
|
||||
|
||||
// If no text content provided, return undefined
|
||||
if (!textContent) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle array of strings or single string
|
||||
const textsToCheck = Array.isArray(textContent) ? textContent : [textContent];
|
||||
|
||||
// Find the first non-empty text
|
||||
const textToCheck = textsToCheck.find((text) => text && text.trim().length > 0) ?? "";
|
||||
|
||||
// Detect direction from text
|
||||
const detected = detectTextDirection(textToCheck);
|
||||
|
||||
// Convert "neutral" to undefined (browsers handle neutral as auto)
|
||||
return detected === "neutral" ? undefined : detected;
|
||||
}, [dir, textContent]);
|
||||
}
|
||||
@@ -1,2 +1,47 @@
|
||||
export { Button, buttonVariants } from "./components/button";
|
||||
export { Input, type InputProps } from "./components/input";
|
||||
export { Button, buttonVariants } from "@/components/general/button";
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
} from "@/components/general/dropdown-menu";
|
||||
export { ElementHeader, type ElementHeaderProps } from "@/components/general/element-header";
|
||||
export { ElementMedia, type ElementMediaProps } from "@/components/general/element-media";
|
||||
export { Input, type InputProps } from "@/components/general/input";
|
||||
export { OpenText, type OpenTextProps } from "@/components/elements/open-text";
|
||||
export {
|
||||
MultiSelect,
|
||||
type MultiSelectProps,
|
||||
type MultiSelectOption,
|
||||
} from "@/components/elements/multi-select";
|
||||
export {
|
||||
SingleSelect,
|
||||
type SingleSelectProps,
|
||||
type SingleSelectOption,
|
||||
} from "@/components/elements/single-select";
|
||||
export { Matrix, type MatrixProps, type MatrixOption } from "@/components/elements/matrix";
|
||||
export { DateElement, type DateElementProps } from "@/components/elements/date";
|
||||
export { getDateFnsLocale } from "@/lib/locale";
|
||||
export {
|
||||
PictureSelect,
|
||||
type PictureSelectProps,
|
||||
type PictureSelectOption,
|
||||
} from "@/components/elements/picture-select";
|
||||
export { FileUpload, type FileUploadProps, type UploadedFile } from "@/components/elements/file-upload";
|
||||
export { FormField, type FormFieldProps, type FormFieldConfig } from "@/components/elements/form-field";
|
||||
export { Rating, type RatingProps } from "@/components/elements/rating";
|
||||
export { NPS, type NPSProps } from "@/components/elements/nps";
|
||||
export { Ranking, type RankingProps, type RankingOption } from "@/components/elements/ranking";
|
||||
export { CTA, type CTAProps } from "@/components/elements/cta";
|
||||
export { Consent, type ConsentProps } from "@/components/elements/consent";
|
||||
|
||||
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;
|
||||
}
|
||||
440
packages/survey-ui/src/lib/story-helpers.tsx
Normal file
440
packages/survey-ui/src/lib/story-helpers.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import type { Decorator, StoryContext } from "@storybook/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// Shared Styling Options Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface BaseStylingOptions {
|
||||
// Element styling
|
||||
elementHeadlineFontFamily: string;
|
||||
elementHeadlineFontSize: string;
|
||||
elementHeadlineFontWeight: string;
|
||||
elementHeadlineColor: string;
|
||||
elementDescriptionFontFamily: string;
|
||||
elementDescriptionFontWeight: string;
|
||||
elementDescriptionFontSize: string;
|
||||
elementDescriptionColor: string;
|
||||
// Input styling
|
||||
inputBgColor: string;
|
||||
inputBorderColor: string;
|
||||
inputColor: string;
|
||||
inputFontSize: string;
|
||||
inputFontWeight: string;
|
||||
// Survey styling
|
||||
brandColor: string;
|
||||
}
|
||||
|
||||
export interface LabelStylingOptions {
|
||||
labelFontFamily: string;
|
||||
labelFontSize: string;
|
||||
labelFontWeight: string;
|
||||
labelColor: string;
|
||||
labelOpacity: string;
|
||||
}
|
||||
|
||||
export interface InputLayoutStylingOptions {
|
||||
inputWidth: string;
|
||||
inputHeight: string;
|
||||
inputBorderRadius: string;
|
||||
inputPlaceholderColor: string;
|
||||
inputPaddingX: string;
|
||||
inputPaddingY: string;
|
||||
}
|
||||
|
||||
export interface OptionStylingOptions {
|
||||
optionBorderColor: string;
|
||||
optionBgColor: string;
|
||||
optionLabelColor: string;
|
||||
optionBorderRadius: string;
|
||||
optionPaddingX: string;
|
||||
optionPaddingY: string;
|
||||
optionFontFamily: string;
|
||||
optionFontSize: string;
|
||||
optionFontWeight: string;
|
||||
}
|
||||
|
||||
export interface ButtonStylingOptions {
|
||||
buttonHeight: string;
|
||||
buttonWidth: string;
|
||||
buttonFontSize: string;
|
||||
buttonBorderRadius: string;
|
||||
buttonBgColor: string;
|
||||
buttonTextColor: string;
|
||||
buttonPaddingX: string;
|
||||
buttonPaddingY: string;
|
||||
}
|
||||
|
||||
export interface CheckboxInputStylingOptions {
|
||||
checkboxInputBorderColor: string;
|
||||
checkboxInputBgColor: string;
|
||||
checkboxInputColor: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Selects a subset of argTypes by key, with type safety.
|
||||
* Useful for stories that only need specific styling controls.
|
||||
*
|
||||
* @example
|
||||
* pickArgTypes(inputStylingArgTypes, ['inputBgColor', 'inputBorderColor'])
|
||||
*/
|
||||
export function pickArgTypes<T extends Record<string, unknown>>(argTypes: T, keys: (keyof T)[]): Partial<T> {
|
||||
const result: Partial<T> = {};
|
||||
for (const key of keys) {
|
||||
if (key in argTypes) {
|
||||
result[key] = argTypes[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Common argTypes Configurations
|
||||
// ============================================================================
|
||||
|
||||
export const commonArgTypes = {
|
||||
headline: {
|
||||
control: "text",
|
||||
description: "The main element text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
description: {
|
||||
control: "text",
|
||||
description: "Optional description or subheader text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
required: {
|
||||
control: "boolean",
|
||||
description: "Whether the field is required",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
errorMessage: {
|
||||
control: "text",
|
||||
description: "Error message to display",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
dir: {
|
||||
control: { type: "select" },
|
||||
options: ["ltr", "rtl", "auto"],
|
||||
description: "Text direction for RTL support",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Whether the input is disabled",
|
||||
table: { category: "State" },
|
||||
},
|
||||
onChange: {
|
||||
action: "changed",
|
||||
table: { category: "Events" },
|
||||
},
|
||||
};
|
||||
|
||||
export const elementStylingArgTypes = {
|
||||
elementHeadlineFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
};
|
||||
|
||||
export const inputStylingArgTypes = {
|
||||
inputBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputWidth: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputHeight: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPlaceholderColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPaddingX: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPaddingY: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
};
|
||||
|
||||
export const labelStylingArgTypes = {
|
||||
labelFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelColor: {
|
||||
control: "color",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelOpacity: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
};
|
||||
|
||||
export const surveyStylingArgTypes = {
|
||||
brandColor: {
|
||||
control: "color",
|
||||
table: { category: "Survey Styling" },
|
||||
},
|
||||
};
|
||||
|
||||
export const optionStylingArgTypes = {
|
||||
optionBorderColor: {
|
||||
control: "color",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
optionBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
optionLabelColor: {
|
||||
control: "color",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
optionBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
optionPaddingX: {
|
||||
control: "text",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
optionPaddingY: {
|
||||
control: "text",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
optionFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
optionFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
optionFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Option Styling" },
|
||||
},
|
||||
};
|
||||
|
||||
export const buttonStylingArgTypes = {
|
||||
buttonHeight: {
|
||||
control: "text",
|
||||
table: { category: "Button Styling" },
|
||||
},
|
||||
buttonWidth: {
|
||||
control: "text",
|
||||
table: { category: "Button Styling" },
|
||||
},
|
||||
buttonFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Button Styling" },
|
||||
},
|
||||
buttonBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Button Styling" },
|
||||
},
|
||||
buttonBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Button Styling" },
|
||||
},
|
||||
buttonTextColor: {
|
||||
control: "color",
|
||||
table: { category: "Button Styling" },
|
||||
},
|
||||
buttonPaddingX: {
|
||||
control: "text",
|
||||
table: { category: "Button Styling" },
|
||||
},
|
||||
buttonPaddingY: {
|
||||
control: "text",
|
||||
table: { category: "Button Styling" },
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// CSS Variable Mapping and Decorator Factory
|
||||
// ============================================================================
|
||||
|
||||
type CSSVarMapping = Record<string, string>;
|
||||
|
||||
const CSS_VAR_MAP: CSSVarMapping = {
|
||||
elementHeadlineFontFamily: "--fb-element-headline-font-family",
|
||||
elementHeadlineFontSize: "--fb-element-headline-font-size",
|
||||
elementHeadlineFontWeight: "--fb-element-headline-font-weight",
|
||||
elementHeadlineColor: "--fb-element-headline-color",
|
||||
elementDescriptionFontFamily: "--fb-element-description-font-family",
|
||||
elementDescriptionFontSize: "--fb-element-description-font-size",
|
||||
elementDescriptionFontWeight: "--fb-element-description-font-weight",
|
||||
elementDescriptionColor: "--fb-element-description-color",
|
||||
inputBgColor: "--fb-input-bg-color",
|
||||
inputBorderColor: "--fb-input-border-color",
|
||||
inputColor: "--fb-input-color",
|
||||
inputFontSize: "--fb-input-font-size",
|
||||
inputFontWeight: "--fb-input-font-weight",
|
||||
inputWidth: "--fb-input-width",
|
||||
inputHeight: "--fb-input-height",
|
||||
inputBorderRadius: "--fb-input-border-radius",
|
||||
inputPlaceholderColor: "--fb-input-placeholder-color",
|
||||
inputPaddingX: "--fb-input-padding-x",
|
||||
inputPaddingY: "--fb-input-padding-y",
|
||||
labelFontFamily: "--fb-label-font-family",
|
||||
labelFontSize: "--fb-label-font-size",
|
||||
labelFontWeight: "--fb-label-font-weight",
|
||||
labelColor: "--fb-label-color",
|
||||
labelOpacity: "--fb-label-opacity",
|
||||
brandColor: "--fb-survey-brand-color",
|
||||
optionBorderColor: "--fb-option-border-color",
|
||||
optionBgColor: "--fb-option-bg-color",
|
||||
optionLabelColor: "--fb-option-label-color",
|
||||
optionBorderRadius: "--fb-option-border-radius",
|
||||
optionPaddingX: "--fb-option-padding-x",
|
||||
optionPaddingY: "--fb-option-padding-y",
|
||||
optionFontFamily: "--fb-option-font-family",
|
||||
optionFontSize: "--fb-option-font-size",
|
||||
optionFontWeight: "--fb-option-font-weight",
|
||||
buttonHeight: "--fb-button-height",
|
||||
buttonWidth: "--fb-button-width",
|
||||
buttonFontSize: "--fb-button-font-size",
|
||||
buttonBorderRadius: "--fb-button-border-radius",
|
||||
buttonBgColor: "--fb-button-bg-color",
|
||||
buttonTextColor: "--fb-button-text-color",
|
||||
buttonPaddingX: "--fb-button-padding-x",
|
||||
buttonPaddingY: "--fb-button-padding-y",
|
||||
checkboxInputBorderColor: "--fb-checkbox-input-border-color",
|
||||
checkboxInputBgColor: "--fb-checkbox-input-bg-color",
|
||||
checkboxInputColor: "--fb-checkbox-input-color",
|
||||
};
|
||||
|
||||
export function createCSSVariablesDecorator<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
width = "600px",
|
||||
additionalMappings?: CSSVarMapping
|
||||
): Decorator<T> {
|
||||
const fullMapping = { ...CSS_VAR_MAP, ...additionalMappings };
|
||||
|
||||
function CSSVariablesDecorator(Story: React.ComponentType, context: StoryContext<T>): React.ReactElement {
|
||||
// Storybook's Decorator type doesn't properly infer args type, so we safely extract it
|
||||
// Access args through a type-safe helper
|
||||
interface ContextWithArgs {
|
||||
args?: T;
|
||||
}
|
||||
const safeContext = context as unknown as ContextWithArgs;
|
||||
const args = (safeContext.args ?? {}) as Record<string, string | undefined>;
|
||||
|
||||
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {};
|
||||
|
||||
Object.entries(fullMapping).forEach(([argKey, cssVar]) => {
|
||||
if (args[argKey] !== undefined) {
|
||||
cssVarStyle[cssVar] = args[argKey];
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={cssVarStyle} className={`w-[${width}]`}>
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CSSVariablesDecorator.displayName = "CSSVariablesDecorator";
|
||||
return CSSVariablesDecorator;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stateful Render Function Creator
|
||||
// ============================================================================
|
||||
|
||||
export function createStatefulRender<
|
||||
TValue,
|
||||
TProps extends { value?: TValue; onChange?: (v: TValue) => void },
|
||||
>(Component: React.ComponentType<TProps>): (args: TProps & Record<string, unknown>) => React.ReactElement {
|
||||
function StatefulRender(args: TProps & Record<string, unknown>): React.ReactElement {
|
||||
const [value, setValue] = useState<TValue | undefined>(args.value);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v: TValue) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
StatefulRender.displayName = "StatefulRender";
|
||||
return StatefulRender;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { extendTailwindMerge } from "tailwind-merge";
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
theme: {
|
||||
// Custom tokens from `packages/survey-ui/tailwind.config.ts`
|
||||
fontSize: ["input", "option", "button"],
|
||||
textColor: ["input-text", "input-placeholder", "option-label", "button-text"],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes
|
||||
|
||||
121
packages/survey-ui/src/lib/video-upload.ts
Normal file
121
packages/survey-ui/src/lib/video-upload.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 (err) {
|
||||
// invalid URL
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkForVimeoUrl = (url: string): boolean => {
|
||||
try {
|
||||
const vimeoUrl = new URL(url);
|
||||
|
||||
if (vimeoUrl.protocol !== "https:") return false;
|
||||
|
||||
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
|
||||
const hostname = vimeoUrl.hostname;
|
||||
|
||||
return vimeoDomains.includes(hostname);
|
||||
} catch (err) {
|
||||
// invalid URL
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkForLoomUrl = (url: string): boolean => {
|
||||
try {
|
||||
const loomUrl = new URL(url);
|
||||
|
||||
if (loomUrl.protocol !== "https:") return false;
|
||||
|
||||
const loomDomains = ["www.loom.com", "loom.com"];
|
||||
const hostname = loomUrl.hostname;
|
||||
|
||||
return loomDomains.includes(hostname);
|
||||
} catch (err) {
|
||||
// invalid URL
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const extractYoutubeId = (url: string): string | null => {
|
||||
let id = "";
|
||||
|
||||
// Regular expressions for various YouTube URL formats
|
||||
const regExpList = [
|
||||
/youtu\.be\/(?<videoId>[a-zA-Z0-9_-]+)/, // youtu.be/<id>
|
||||
/youtube\.com.*v=(?<videoId>[a-zA-Z0-9_-]+)/, // youtube.com/watch?v=<id>
|
||||
/youtube\.com.*embed\/(?<videoId>[a-zA-Z0-9_-]+)/, // youtube.com/embed/<id>
|
||||
/youtube-nocookie\.com\/embed\/(?<videoId>[a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/<id>
|
||||
];
|
||||
|
||||
regExpList.some((regExp) => {
|
||||
const match = url.match(regExp);
|
||||
if (match?.groups?.videoId) {
|
||||
id = match.groups.videoId;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return id || null;
|
||||
};
|
||||
|
||||
const extractVimeoId = (url: string): string | null => {
|
||||
const regExp = /vimeo\.com\/(?<videoId>\d+)/;
|
||||
const match = regExp.exec(url);
|
||||
|
||||
return match?.groups?.videoId ?? null;
|
||||
};
|
||||
|
||||
const extractLoomId = (url: string): string | null => {
|
||||
const regExp = /loom\.com\/share\/(?<videoId>[a-zA-Z0-9]+)/;
|
||||
const match = regExp.exec(url);
|
||||
|
||||
return match?.groups?.videoId ?? null;
|
||||
};
|
||||
|
||||
// Always convert a given URL into its embed form if supported.
|
||||
export const convertToEmbedUrl = (url: string): string | undefined => {
|
||||
// YouTube
|
||||
if (checkForYoutubeUrl(url)) {
|
||||
const videoId = extractYoutubeId(url);
|
||||
if (videoId) {
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Vimeo
|
||||
if (checkForVimeoUrl(url)) {
|
||||
const videoId = extractVimeoId(url);
|
||||
if (videoId) {
|
||||
return `https://player.vimeo.com/video/${videoId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Loom
|
||||
if (checkForLoomUrl(url)) {
|
||||
const videoId = extractLoomId(url);
|
||||
if (videoId) {
|
||||
return `https://www.loom.com/embed/${videoId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// If no supported platform found, return undefined
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,57 +1,223 @@
|
||||
/* =============================================================================
|
||||
Survey UI - Global Styles
|
||||
|
||||
This file contains:
|
||||
1. Tailwind CSS setup and configuration
|
||||
2. Base design tokens (colors, radius)
|
||||
3. Survey-specific theming tokens (--fb-* variables)
|
||||
|
||||
All tokens are designed to be overridden by consumers for custom theming.
|
||||
Components use Tailwind utilities backed by these CSS variables.
|
||||
============================================================================= */
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities) important;
|
||||
|
||||
@config "../../tailwind.config.ts";
|
||||
@source "../../src/**/*.{ts,tsx,js,jsx}";
|
||||
|
||||
/* =============================================================================
|
||||
Design Tokens
|
||||
============================================================================= */
|
||||
|
||||
:root {
|
||||
/* Base variables used by other variables */
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--radius: 0.5rem;
|
||||
/* ---------------------------------------------------------------------------
|
||||
Base Primitives
|
||||
These are the foundation tokens used throughout the design system.
|
||||
--------------------------------------------------------------------------- */
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-muted: oklch(0.97 0.02 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: black;
|
||||
|
||||
/* Question Headline CSS variables */
|
||||
--fb-question-headline-font-family: inherit;
|
||||
--fb-question-headline-font-weight: 600;
|
||||
--fb-question-headline-font-size: 1.125rem;
|
||||
--fb-question-headline-color: hsl(222.2 84% 4.9%);
|
||||
--fb-question-headline-opacity: 1;
|
||||
/* ---------------------------------------------------------------------------
|
||||
Survey Brand Color
|
||||
The primary accent color used throughout the survey. Override this to
|
||||
customize the brand appearance.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-survey-brand-color: #64748b;
|
||||
|
||||
/* Question Description CSS variables */
|
||||
--fb-question-description-font-family: inherit;
|
||||
--fb-question-description-font-weight: 400;
|
||||
--fb-question-description-font-size: 0.875rem;
|
||||
--fb-question-description-color: hsl(215.4 16.3% 46.9%);
|
||||
--fb-question-description-opacity: 1;
|
||||
/* ---------------------------------------------------------------------------
|
||||
Element Headline Tokens
|
||||
Used for question headlines and main element titles.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-element-headline-font-family: inherit;
|
||||
--fb-element-headline-font-weight: 600;
|
||||
--fb-element-headline-font-size: 1.125rem;
|
||||
--fb-element-headline-color: hsl(222.2 84% 4.9%);
|
||||
--fb-element-headline-opacity: 1;
|
||||
|
||||
/* Label CSS variables */
|
||||
--fb-label-font-family: inherit;
|
||||
--fb-label-font-weight: 500;
|
||||
--fb-label-font-size: 0.875rem;
|
||||
--fb-label-color: var(--foreground);
|
||||
--fb-label-opacity: 1;
|
||||
/* ---------------------------------------------------------------------------
|
||||
Element Description Tokens
|
||||
Used for descriptive text below headlines.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-element-description-font-family: inherit;
|
||||
--fb-element-description-font-weight: 400;
|
||||
--fb-element-description-font-size: 0.875rem;
|
||||
--fb-element-description-color: hsl(215.4 16.3% 46.9%);
|
||||
--fb-element-description-opacity: 1;
|
||||
|
||||
/* Button CSS variables */
|
||||
--fb-button-height: 2.25rem;
|
||||
--fb-button-width: auto;
|
||||
--fb-button-font-size: 0.875rem;
|
||||
--fb-button-border-radius: var(--radius);
|
||||
--fb-button-bg-color: hsl(222.2 47.4% 11.2%);
|
||||
--fb-button-text-color: hsl(210 40% 98%);
|
||||
--fb-button-padding-x: 1rem;
|
||||
--fb-button-padding-y: 0.5rem;
|
||||
/* ---------------------------------------------------------------------------
|
||||
Label Tokens
|
||||
Used for form labels and secondary text.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-label-font-family: inherit;
|
||||
--fb-label-font-weight: 500;
|
||||
--fb-label-font-size: 0.875rem;
|
||||
--fb-label-color: var(--foreground);
|
||||
--fb-label-opacity: 1;
|
||||
|
||||
/* Input CSS variables */
|
||||
--fb-input-bg-color: transparent;
|
||||
--fb-input-bg-opacity: 1;
|
||||
--fb-input-hover-bg-color: var(--input);
|
||||
--fb-input-hover-bg-opacity: 0.3;
|
||||
--fb-input-border-color: var(--input);
|
||||
--fb-input-border-radius: var(--radius);
|
||||
--fb-input-font-family: inherit;
|
||||
--fb-input-font-size: 0.875rem;
|
||||
--fb-input-font-weight: 400;
|
||||
--fb-input-color: var(--foreground);
|
||||
--fb-input-placeholder-color: var(--muted-foreground);
|
||||
--fb-input-placeholder-opacity: 1;
|
||||
--fb-input-width: 100%;
|
||||
--fb-input-height: 2.25rem;
|
||||
--fb-input-padding-x: 0.75rem;
|
||||
--fb-input-padding-y: 0.25rem;
|
||||
--fb-input-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
/* ---------------------------------------------------------------------------
|
||||
Button Tokens
|
||||
Used for the custom button variant. Standard variants use Tailwind defaults.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-button-height: 2.25rem;
|
||||
--fb-button-width: auto;
|
||||
--fb-button-font-size: 0.875rem;
|
||||
--fb-button-font-family: inherit;
|
||||
--fb-button-font-weight: 500;
|
||||
--fb-button-border-radius: var(--radius);
|
||||
--fb-button-bg-color: hsl(222.2 47.4% 11.2%);
|
||||
--fb-button-text-color: hsl(210 40% 98%);
|
||||
--fb-button-padding-x: 1rem;
|
||||
--fb-button-padding-y: 0.5rem;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Input Tokens
|
||||
Used for text inputs, textareas, and other form controls.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-input-bg-color: white;
|
||||
--fb-input-border-color: var(--fb-survey-brand-color);
|
||||
--fb-input-border-radius: var(--radius);
|
||||
--fb-input-font-family: inherit;
|
||||
--fb-input-font-size: 0.875rem;
|
||||
--fb-input-font-weight: 400;
|
||||
--fb-input-color: var(--foreground);
|
||||
--fb-input-placeholder-color: var(--muted-foreground);
|
||||
--fb-input-placeholder-opacity: 1;
|
||||
--fb-input-width: 100%;
|
||||
--fb-input-height: 40px;
|
||||
--fb-input-padding-x: 16px;
|
||||
--fb-input-padding-y: 16px;
|
||||
--fb-input-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Progress Tokens
|
||||
Used for the Progress component track and indicator.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-progress-track-height: 0.5rem;
|
||||
--fb-progress-track-bg-color: hsl(222.2 47.4% 11.2% / 0.2);
|
||||
--fb-progress-track-border-radius: var(--radius);
|
||||
--fb-progress-indicator-bg-color: hsl(222.2 47.4% 11.2%);
|
||||
--fb-progress-indicator-border-radius: var(--radius);
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Option Tokens
|
||||
Used for selectable options (radio, checkbox, multi-select).
|
||||
These inherit from input tokens by default for consistency.
|
||||
--------------------------------------------------------------------------- */
|
||||
--fb-option-border-color: var(--fb-input-border-color);
|
||||
--fb-option-bg-color: var(--fb-input-bg-color);
|
||||
--fb-option-label-color: var(--fb-input-color);
|
||||
--fb-option-border-radius: var(--fb-input-border-radius);
|
||||
--fb-option-padding-x: var(--fb-input-padding-x);
|
||||
--fb-option-padding-y: var(--fb-input-padding-y);
|
||||
--fb-option-font-family: var(--fb-input-font-family);
|
||||
--fb-option-font-size: var(--fb-input-font-size);
|
||||
--fb-option-font-weight: var(--fb-input-font-weight);
|
||||
}
|
||||
|
||||
.button-custom {
|
||||
width: var(--fb-button-width);
|
||||
height: var(--fb-button-height);
|
||||
background-color: var(--fb-button-bg-color);
|
||||
border-radius: var(--fb-button-border-radius);
|
||||
font-family: var(--fb-button-font-family);
|
||||
font-weight: var(--fb-button-font-weight);
|
||||
padding-left: var(--fb-button-padding-x);
|
||||
padding-right: var(--fb-button-padding-x);
|
||||
padding-top: var(--fb-button-padding-y);
|
||||
padding-bottom: var(--fb-button-padding-y);
|
||||
color: var(--fb-button-text-color);
|
||||
font-size: var(--fb-button-font-size);
|
||||
}
|
||||
|
||||
.label-headline {
|
||||
font-family: var(--fb-element-headline-font-family);
|
||||
font-weight: var(--fb-element-headline-font-weight);
|
||||
font-size: var(--fb-element-headline-font-size);
|
||||
color: var(--fb-element-headline-color);
|
||||
opacity: var(--fb-element-headline-opacity);
|
||||
}
|
||||
|
||||
.label-description {
|
||||
font-family: var(--fb-element-description-font-family);
|
||||
font-weight: var(--fb-element-description-font-weight);
|
||||
font-size: var(--fb-element-description-font-size);
|
||||
color: var(--fb-element-description-color);
|
||||
opacity: var(--fb-element-description-opacity);
|
||||
}
|
||||
|
||||
.label-default {
|
||||
font-family: var(--fb-label-font-family);
|
||||
font-weight: var(--fb-label-font-weight);
|
||||
font-size: var(--fb-label-font-size);
|
||||
color: var(--fb-label-color);
|
||||
opacity: var(--fb-label-opacity);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: var(--fb-progress-track-height);
|
||||
background-color: var(--fb-progress-track-bg-color);
|
||||
border-radius: var(--fb-progress-track-border-radius);
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
background-color: var(--fb-progress-indicator-bg-color);
|
||||
border-radius: var(--fb-progress-indicator-border-radius);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Textarea Scrollbar Styling
|
||||
Smaller, more subtle scrollbars for textarea elements.
|
||||
--------------------------------------------------------------------------- */
|
||||
textarea {
|
||||
/* Firefox */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(215.4 16.3% 46.9% / 0.3) transparent;
|
||||
}
|
||||
|
||||
/* Chrome, Edge, and Safari */
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(215.4 16.3% 46.9% / 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(215.4 16.3% 46.9% / 0.5);
|
||||
}
|
||||
@@ -1,6 +1,107 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
darkMode: "class",
|
||||
content: ["./src/**/*.{tsx,ts,jsx,js}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
card: {
|
||||
DEFAULT: "var(--card)",
|
||||
foreground: "var(--card-foreground)",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "var(--popover)",
|
||||
foreground: "var(--popover-foreground)",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "var(--primary)",
|
||||
foreground: "var(--primary-foreground)",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "var(--secondary)",
|
||||
foreground: "var(--secondary-foreground)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "var(--accent)",
|
||||
foreground: "var(--accent-foreground)",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "var(--destructive)",
|
||||
foreground: "var(--foreground)",
|
||||
muted: "var(--destructive-muted)",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "var(--muted)",
|
||||
foreground: "var(--muted-foreground)",
|
||||
},
|
||||
border: "var(--border)",
|
||||
input: "var(--input)",
|
||||
ring: "var(--fb-survey-brand-color)",
|
||||
brand: {
|
||||
DEFAULT: "var(--fb-survey-brand-color)",
|
||||
"20": "color-mix(in srgb, var(--fb-survey-brand-color) 20%, white)",
|
||||
},
|
||||
// Input CSS variables (shorter names)
|
||||
"input-bg": "var(--fb-input-bg-color)",
|
||||
"input-border": "var(--fb-input-border-color, var(--fb-survey-brand-color))",
|
||||
"input-text": "var(--fb-input-color)",
|
||||
"input-placeholder": "var(--fb-input-placeholder-color)",
|
||||
// Option CSS variables
|
||||
"option-bg": "var(--fb-option-bg-color)",
|
||||
"option-border": "var(--fb-option-border-color)",
|
||||
"option-label": "var(--fb-option-label-color)",
|
||||
"option-selected-bg": "color-mix(in srgb, var(--fb-option-bg-color) 90%, black)",
|
||||
"option-hover-bg": "color-mix(in srgb, var(--fb-option-bg-color) 90%, black)",
|
||||
"input-selected-bg": "color-mix(in srgb, var(--fb-input-bg-color) 90%, black)",
|
||||
// Button CSS variables
|
||||
"button-bg": "var(--fb-button-bg-color)",
|
||||
"button-text": "var(--fb-button-text-color)",
|
||||
},
|
||||
width: {
|
||||
input: "var(--fb-input-width)",
|
||||
button: "var(--fb-button-width)",
|
||||
},
|
||||
height: {
|
||||
input: "var(--fb-input-height)",
|
||||
button: "var(--fb-button-height)",
|
||||
},
|
||||
borderRadius: {
|
||||
input: "var(--fb-input-border-radius)",
|
||||
option: "var(--fb-option-border-radius)",
|
||||
button: "var(--fb-button-border-radius)",
|
||||
},
|
||||
fontSize: {
|
||||
input: "var(--fb-input-font-size)",
|
||||
option: "var(--fb-option-font-size)",
|
||||
button: "var(--fb-button-font-size)",
|
||||
},
|
||||
fontWeight: {
|
||||
"input-weight": "var(--fb-input-font-weight)",
|
||||
"option-weight": "var(--fb-option-font-weight)",
|
||||
"button-weight": "var(--fb-button-font-weight)",
|
||||
},
|
||||
fontFamily: {
|
||||
input: "var(--fb-input-font-family)",
|
||||
option: "var(--fb-option-font-family)",
|
||||
button: "var(--fb-button-font-family)",
|
||||
},
|
||||
padding: {
|
||||
"input-x": "var(--fb-input-padding-x)",
|
||||
"input-y": "var(--fb-input-padding-y)",
|
||||
"option-x": "var(--fb-option-padding-x)",
|
||||
"option-y": "var(--fb-option-padding-y)",
|
||||
"button-x": "var(--fb-button-padding-x)",
|
||||
"button-y": "var(--fb-button-padding-y)",
|
||||
},
|
||||
boxShadow: {
|
||||
input: "var(--fb-input-shadow)",
|
||||
},
|
||||
opacity: {
|
||||
"input-placeholder": "var(--fb-input-placeholder-opacity)",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -12,6 +13,6 @@ export default defineConfig({
|
||||
external: ["react", "react-dom"],
|
||||
},
|
||||
},
|
||||
plugins: [dts({ include: ["src"] })],
|
||||
plugins: [tsconfigPaths(), dts({ include: ["src"] })],
|
||||
});
|
||||
|
||||
|
||||
2574
pnpm-lock.yaml
generated
2574
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user