From 15dc83a4eb5fcebef71820a47e3a2edf66df7b3f Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:43:28 +0530 Subject: [PATCH] feat: improved survey UI (#6988) Co-authored-by: Matti Nannt Co-authored-by: Johannes Co-authored-by: pandeymangg --- .cursor/commands/create-question.md | 352 + apps/storybook/.storybook/main.ts | 27 +- apps/storybook/.storybook/preview.ts | 31 +- apps/storybook/package.json | 30 +- apps/storybook/postcss.config.js | 6 - apps/storybook/tailwind.config.js | 14 +- apps/storybook/vite.config.ts | 5 +- apps/web/modules/email/emails/lib/utils.tsx | 4 +- .../emails/survey/response-finished-email.tsx | 4 +- .../survey/editor/components/block-card.tsx | 2 +- .../editor/components/survey-menu-bar.tsx | 2 +- .../follow-ups/components/follow-up-email.tsx | 4 +- .../follow-ups/components/follow-up-item.tsx | 2 +- .../modules/ui/components/select/index.tsx | 6 +- apps/web/package.json | 3 +- apps/web/playwright/js.spec.ts | 6 +- apps/web/playwright/survey.spec.ts | 56 +- package.json | 4 +- .../src/scripts/generate-data-migration.ts | 2 +- .../database/src/scripts/migration-runner.ts | 2 +- packages/survey-ui/.eslintrc.cjs | 5 + packages/survey-ui/.gitignore | 8 + packages/survey-ui/README.md | 101 + packages/survey-ui/components.json | 20 + packages/survey-ui/package.json | 80 + packages/survey-ui/postcss.config.mjs | 5 + .../components/elements/consent.stories.tsx | 188 + .../src/components/elements/consent.tsx | 92 + .../src/components/elements/cta.stories.tsx | 186 + .../survey-ui/src/components/elements/cta.tsx | 89 + .../src/components/elements/date.stories.tsx | 315 + .../src/components/elements/date.tsx | 148 + .../elements/file-upload.stories.tsx | 244 + .../src/components/elements/file-upload.tsx | 336 + .../elements/form-field.stories.tsx | 362 + .../src/components/elements/form-field.tsx | 140 + .../components/elements/matrix.stories.tsx | 307 + .../src/components/elements/matrix.tsx | 166 + .../elements/multi-select.stories.tsx | 353 + .../src/components/elements/multi-select.tsx | 549 ++ .../src/components/elements/nps.stories.tsx | 244 + .../survey-ui/src/components/elements/nps.tsx | 196 + .../components/elements/open-text.stories.tsx | 279 + .../src/components/elements/open-text.tsx | 110 + .../elements/picture-select.stories.tsx | 282 + .../components/elements/picture-select.tsx | 198 + .../components/elements/ranking.stories.tsx | 222 + .../src/components/elements/ranking.tsx | 263 + .../components/elements/rating.stories.tsx | 320 + .../src/components/elements/rating.tsx | 442 + .../elements/single-select.stories.tsx | 382 + .../src/components/elements/single-select.tsx | 315 + .../src/components/general/alert.stories.tsx | 66 + .../src/components/general/alert.tsx | 54 + .../src/components/general/button.stories.tsx | 243 + .../src/components/general/button.tsx | 58 + .../src/components/general/calendar.tsx | 218 + .../components/general/checkbox.stories.tsx | 90 + .../src/components/general/checkbox.tsx | 27 + .../general/dropdown-menu.stories.tsx | 188 + .../src/components/general/dropdown-menu.tsx | 218 + .../src/components/general/element-error.tsx | 36 + .../general/element-header.stories.tsx | 111 + .../src/components/general/element-header.tsx | 102 + .../src/components/general/element-media.tsx | 102 + .../src/components/general/input.stories.tsx | 294 + .../src/components/general/input.tsx | 61 + .../src/components/general/label.stories.tsx | 405 + .../src/components/general/label.tsx | 123 + .../src/components/general/popover.tsx | 39 + .../components/general/progress.stories.tsx | 155 + .../src/components/general/progress.tsx | 29 + .../general/radio-group.stories.tsx | 312 + .../src/components/general/radio-group.tsx | 58 + .../src/components/general/smileys.tsx | 466 ++ .../components/general/textarea.stories.tsx | 153 + .../src/components/general/textarea.tsx | 30 + packages/survey-ui/src/index.ts | 49 + packages/survey-ui/src/lib/locale.test.ts | 113 + packages/survey-ui/src/lib/locale.ts | 52 + packages/survey-ui/src/lib/story-helpers.tsx | 442 + packages/survey-ui/src/lib/utils.test.ts | 45 + packages/survey-ui/src/lib/utils.ts | 37 + packages/survey-ui/src/lib/video.test.ts | 154 + packages/survey-ui/src/lib/video.ts | 121 + packages/survey-ui/src/styles/globals.css | 232 + packages/survey-ui/tailwind.config.ts | 118 + packages/survey-ui/tsconfig.json | 15 + packages/survey-ui/vite.config.mts | 67 + packages/surveys/i18n.lock | 17 +- packages/surveys/locales/ar.json | 17 +- packages/surveys/locales/de.json | 19 +- packages/surveys/locales/en.json | 17 +- packages/surveys/locales/es.json | 17 +- packages/surveys/locales/fr.json | 17 +- packages/surveys/locales/hi.json | 17 +- packages/surveys/locales/it.json | 17 +- packages/surveys/locales/ja.json | 17 +- packages/surveys/locales/nl.json | 17 +- packages/surveys/locales/pt.json | 17 +- packages/surveys/locales/ro.json | 17 +- packages/surveys/locales/ru.json | 17 +- packages/surveys/locales/sv.json | 17 +- packages/surveys/locales/uz.json | 17 +- packages/surveys/locales/zh-Hans.json | 17 +- packages/surveys/package.json | 9 +- packages/surveys/postcss.config.cjs | 2 +- .../src/components/buttons/back-button.tsx | 2 +- .../src/components/buttons/submit-button.tsx | 2 +- .../components/elements/address-element.tsx | 208 +- .../src/components/elements/cal-element.tsx | 4 +- .../components/elements/consent-element.tsx | 87 +- .../elements/contact-info-element.tsx | 185 +- .../src/components/elements/cta-element.tsx | 65 +- .../src/components/elements/date-element.tsx | 270 +- .../elements/file-upload-element.tsx | 375 +- .../components/elements/matrix-element.tsx | 198 +- .../multiple-choice-multi-element.tsx | 467 +- .../multiple-choice-single-element.tsx | 311 +- .../src/components/elements/nps-element.tsx | 121 +- .../components/elements/open-text-element.tsx | 182 +- .../elements/picture-selection-element.tsx | 218 +- .../components/elements/ranking-element.tsx | 309 +- .../components/elements/rating-element.tsx | 341 +- .../general/auto-close-progress-bar.tsx | 4 +- .../components/general/block-conditional.tsx | 10 +- .../src/components/general/cal-embed.tsx | 6 +- .../src/components/general/element-media.tsx | 17 +- .../src/components/general/ending-card.tsx | 16 +- .../components/general/error-component.tsx | 11 +- .../src/components/general/file-input.tsx | 443 - .../general/formbricks-branding.tsx | 6 +- .../src/components/general/headline.tsx | 15 +- .../surveys/src/components/general/input.tsx | 2 +- .../surveys/src/components/general/label.tsx | 2 +- .../components/general/language-switch.tsx | 10 +- .../components/general/loading-spinner.tsx | 8 +- .../src/components/general/progress.tsx | 4 +- .../components/general/recaptcha-branding.tsx | 2 +- .../general/response-error-component.tsx | 18 +- .../src/components/general/subheader.tsx | 4 +- .../general/survey-close-button.tsx | 4 +- .../surveys/src/components/general/survey.tsx | 26 +- .../src/components/general/welcome-card.tsx | 26 +- .../wrappers/auto-close-wrapper.tsx | 6 +- .../wrappers/scrollable-container.tsx | 12 +- .../src/components/wrappers/stacked-card.tsx | 6 +- .../wrappers/stacked-cards-container.tsx | 4 +- .../components/wrappers/survey-container.tsx | 32 +- packages/surveys/src/lib/html-utils.ts | 16 +- packages/surveys/src/lib/styles.test.ts | 5 +- packages/surveys/src/lib/styles.ts | 21 +- packages/surveys/src/styles/date-picker.css | 115 - packages/surveys/src/styles/global.css | 43 +- packages/surveys/src/styles/preflight.css | 58 +- packages/surveys/tailwind.config.cjs | 4 - packages/surveys/vite.config.mts | 35 +- pnpm-lock.yaml | 7183 ++++++++++------- sonar-project.properties | 14 +- turbo.json | 32 + 160 files changed, 18354 insertions(+), 6088 deletions(-) create mode 100644 .cursor/commands/create-question.md delete mode 100644 apps/storybook/postcss.config.js create mode 100644 packages/survey-ui/.eslintrc.cjs create mode 100644 packages/survey-ui/.gitignore create mode 100644 packages/survey-ui/README.md create mode 100644 packages/survey-ui/components.json create mode 100644 packages/survey-ui/package.json create mode 100644 packages/survey-ui/postcss.config.mjs create mode 100644 packages/survey-ui/src/components/elements/consent.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/consent.tsx create mode 100644 packages/survey-ui/src/components/elements/cta.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/cta.tsx create mode 100644 packages/survey-ui/src/components/elements/date.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/date.tsx create mode 100644 packages/survey-ui/src/components/elements/file-upload.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/file-upload.tsx create mode 100644 packages/survey-ui/src/components/elements/form-field.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/form-field.tsx create mode 100644 packages/survey-ui/src/components/elements/matrix.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/matrix.tsx create mode 100644 packages/survey-ui/src/components/elements/multi-select.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/multi-select.tsx create mode 100644 packages/survey-ui/src/components/elements/nps.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/nps.tsx create mode 100644 packages/survey-ui/src/components/elements/open-text.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/open-text.tsx create mode 100644 packages/survey-ui/src/components/elements/picture-select.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/picture-select.tsx create mode 100644 packages/survey-ui/src/components/elements/ranking.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/ranking.tsx create mode 100644 packages/survey-ui/src/components/elements/rating.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/rating.tsx create mode 100644 packages/survey-ui/src/components/elements/single-select.stories.tsx create mode 100644 packages/survey-ui/src/components/elements/single-select.tsx create mode 100644 packages/survey-ui/src/components/general/alert.stories.tsx create mode 100644 packages/survey-ui/src/components/general/alert.tsx create mode 100644 packages/survey-ui/src/components/general/button.stories.tsx create mode 100644 packages/survey-ui/src/components/general/button.tsx create mode 100644 packages/survey-ui/src/components/general/calendar.tsx create mode 100644 packages/survey-ui/src/components/general/checkbox.stories.tsx create mode 100644 packages/survey-ui/src/components/general/checkbox.tsx create mode 100644 packages/survey-ui/src/components/general/dropdown-menu.stories.tsx create mode 100644 packages/survey-ui/src/components/general/dropdown-menu.tsx create mode 100644 packages/survey-ui/src/components/general/element-error.tsx create mode 100644 packages/survey-ui/src/components/general/element-header.stories.tsx create mode 100644 packages/survey-ui/src/components/general/element-header.tsx create mode 100644 packages/survey-ui/src/components/general/element-media.tsx create mode 100644 packages/survey-ui/src/components/general/input.stories.tsx create mode 100644 packages/survey-ui/src/components/general/input.tsx create mode 100644 packages/survey-ui/src/components/general/label.stories.tsx create mode 100644 packages/survey-ui/src/components/general/label.tsx create mode 100644 packages/survey-ui/src/components/general/popover.tsx create mode 100644 packages/survey-ui/src/components/general/progress.stories.tsx create mode 100644 packages/survey-ui/src/components/general/progress.tsx create mode 100644 packages/survey-ui/src/components/general/radio-group.stories.tsx create mode 100644 packages/survey-ui/src/components/general/radio-group.tsx create mode 100644 packages/survey-ui/src/components/general/smileys.tsx create mode 100644 packages/survey-ui/src/components/general/textarea.stories.tsx create mode 100644 packages/survey-ui/src/components/general/textarea.tsx create mode 100644 packages/survey-ui/src/index.ts create mode 100644 packages/survey-ui/src/lib/locale.test.ts create mode 100644 packages/survey-ui/src/lib/locale.ts create mode 100644 packages/survey-ui/src/lib/story-helpers.tsx create mode 100644 packages/survey-ui/src/lib/utils.test.ts create mode 100644 packages/survey-ui/src/lib/utils.ts create mode 100644 packages/survey-ui/src/lib/video.test.ts create mode 100644 packages/survey-ui/src/lib/video.ts create mode 100644 packages/survey-ui/src/styles/globals.css create mode 100644 packages/survey-ui/tailwind.config.ts create mode 100644 packages/survey-ui/tsconfig.json create mode 100644 packages/survey-ui/vite.config.mts delete mode 100644 packages/surveys/src/components/general/file-input.tsx delete mode 100644 packages/surveys/src/styles/date-picker.css diff --git a/.cursor/commands/create-question.md b/.cursor/commands/create-question.md new file mode 100644 index 0000000000..c34efdcebe --- /dev/null +++ b/.cursor/commands/create-question.md @@ -0,0 +1,352 @@ +# Create New Question Element + +Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`. + +## Usage + +When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps: + +1. **Create the component file** `{question-type}.tsx` with this structure: + +```typescript +import * as React from "react"; +import { ElementHeader } from "../components/element-header"; +import { useTextDirection } from "../hooks/use-text-direction"; +import { cn } from "../lib/utils"; + +interface {QuestionType}Props { + /** Unique identifier for the element container */ + elementId: string; + /** The main question or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Unique identifier for the input/control group */ + inputId: string; + /** Current value */ + value?: {ValueType}; + /** Callback function called when the value changes */ + onChange: (value: {ValueType}) => void; + /** Whether the field is required (shows asterisk indicator) */ + required?: boolean; + /** Error message to display */ + errorMessage?: string; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: "ltr" | "rtl" | "auto"; + /** Whether the controls are disabled */ + disabled?: boolean; + // Add question-specific props here +} + +function {QuestionType}({ + elementId, + headline, + description, + inputId, + value, + onChange, + required = false, + errorMessage, + dir = "auto", + disabled = false, + // ... question-specific props +}: {QuestionType}Props): React.JSX.Element { + // Ensure value is always the correct type (handle undefined/null) + const currentValue = value ?? {defaultValue}; + + // Detect text direction from content + const detectedDir = useTextDirection({ + dir, + textContent: [headline, description ?? "", /* add other text content from question */], + }); + + return ( +
+ {/* Headline */} + + + {/* Question-specific controls */} + {/* TODO: Add your question-specific UI here */} + + {/* Error message */} + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ ); +} + +export { {QuestionType} }; +export type { {QuestionType}Props }; +``` + +2. **Create the Storybook file** `{question-type}.stories.tsx`: + +```typescript +import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { {QuestionType}, type {QuestionType}Props } from "./{question-type}"; + +// Styling options for the StylingPlayground story +interface StylingOptions { + // Question styling + questionHeadlineFontFamily: string; + questionHeadlineFontSize: string; + questionHeadlineFontWeight: string; + questionHeadlineColor: string; + questionDescriptionFontFamily: string; + questionDescriptionFontWeight: string; + questionDescriptionFontSize: string; + questionDescriptionColor: string; + // Add component-specific styling options here +} + +type StoryProps = {QuestionType}Props & Partial; + +const meta: Meta = { + title: "UI-package/Elements/{QuestionType}", + component: {QuestionType}, + parameters: { + layout: "centered", + docs: { + description: { + component: "A complete {question type} question element...", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + headline: { + control: "text", + description: "The main question text", + table: { category: "Content" }, + }, + description: { + control: "text", + description: "Optional description or subheader text", + table: { category: "Content" }, + }, + value: { + control: "object", + description: "Current value", + table: { category: "State" }, + }, + required: { + control: "boolean", + description: "Whether the field is required", + table: { category: "Validation" }, + }, + errorMessage: { + control: "text", + description: "Error message to display", + table: { category: "Validation" }, + }, + dir: { + control: { type: "select" }, + options: ["ltr", "rtl", "auto"], + description: "Text direction for RTL support", + table: { category: "Layout" }, + }, + disabled: { + control: "boolean", + description: "Whether the controls are disabled", + table: { category: "State" }, + }, + onChange: { + action: "changed", + table: { category: "Events" }, + }, + // Add question-specific argTypes here + }, +}; + +export default meta; +type Story = StoryObj; + +// Decorator to apply CSS variables from story args +const withCSSVariables: Decorator = (Story, context) => { + const args = context.args as StoryProps; + const { + questionHeadlineFontFamily, + questionHeadlineFontSize, + questionHeadlineFontWeight, + questionHeadlineColor, + questionDescriptionFontFamily, + questionDescriptionFontSize, + questionDescriptionFontWeight, + questionDescriptionColor, + // Extract component-specific styling options + } = args; + + const cssVarStyle: React.CSSProperties & Record = { + "--fb-question-headline-font-family": questionHeadlineFontFamily, + "--fb-question-headline-font-size": questionHeadlineFontSize, + "--fb-question-headline-font-weight": questionHeadlineFontWeight, + "--fb-question-headline-color": questionHeadlineColor, + "--fb-question-description-font-family": questionDescriptionFontFamily, + "--fb-question-description-font-size": questionDescriptionFontSize, + "--fb-question-description-font-weight": questionDescriptionFontWeight, + "--fb-question-description-color": questionDescriptionColor, + // Add component-specific CSS variables + }; + + return ( +
+ +
+ ); +}; + +export const StylingPlayground: Story = { + args: { + headline: "Example question?", + description: "Example description", + // Default styling values + questionHeadlineFontFamily: "system-ui, sans-serif", + questionHeadlineFontSize: "1.125rem", + questionHeadlineFontWeight: "600", + questionHeadlineColor: "#1e293b", + questionDescriptionFontFamily: "system-ui, sans-serif", + questionDescriptionFontSize: "0.875rem", + questionDescriptionFontWeight: "400", + questionDescriptionColor: "#64748b", + // Add component-specific default values + }, + argTypes: { + // Question styling argTypes + questionHeadlineFontFamily: { + control: "text", + table: { category: "Question Styling" }, + }, + questionHeadlineFontSize: { + control: "text", + table: { category: "Question Styling" }, + }, + questionHeadlineFontWeight: { + control: "text", + table: { category: "Question Styling" }, + }, + questionHeadlineColor: { + control: "color", + table: { category: "Question Styling" }, + }, + questionDescriptionFontFamily: { + control: "text", + table: { category: "Question Styling" }, + }, + questionDescriptionFontSize: { + control: "text", + table: { category: "Question Styling" }, + }, + questionDescriptionFontWeight: { + control: "text", + table: { category: "Question Styling" }, + }, + questionDescriptionColor: { + control: "color", + table: { category: "Question Styling" }, + }, + // Add component-specific argTypes + }, + decorators: [withCSSVariables], +}; + +export const Default: Story = { + args: { + headline: "Example question?", + // Add default props + }, +}; + +export const WithDescription: Story = { + args: { + headline: "Example question?", + description: "Example description text", + }, +}; + +export const Required: Story = { + args: { + headline: "Example question?", + required: true, + }, +}; + +export const WithError: Story = { + args: { + headline: "Example question?", + errorMessage: "This field is required", + required: true, + }, +}; + +export const Disabled: Story = { + args: { + headline: "Example question?", + disabled: true, + }, +}; + +export const RTL: Story = { + args: { + headline: "مثال على السؤال؟", + description: "مثال على الوصف", + // Add RTL-specific props + }, +}; +``` + +3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed: + +```css +/* Component-specific CSS variables */ +--fb-{component}-{property}: {default-value}; +``` + +4. **Export from** `packages/survey-ui/src/index.ts`: + +```typescript +export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}"; +``` + +## Key Requirements + +- ✅ Always use `ElementHeader` component for headline/description +- ✅ Always use `useTextDirection` hook for RTL support +- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`) +- ✅ Always include error message display if applicable +- ✅ Always support disabled state if applicable +- ✅ Always add JSDoc comments to props interface +- ✅ Always create Storybook stories with styling playground +- ✅ Always export types from component file +- ✅ Always add to index.ts exports + +## Examples + +- `open-text.tsx` - Text input/textarea question (string value) +- `multi-select.tsx` - Multiple checkbox selection (string[] value) + +## Checklist + +When creating a new question element, verify: + +- [ ] Component file created with proper structure +- [ ] Props interface with JSDoc comments for all props +- [ ] Uses `ElementHeader` component (don't duplicate header logic) +- [ ] Uses `useTextDirection` hook for RTL support +- [ ] Handles undefined/null values safely +- [ ] Storybook file created with styling playground +- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL +- [ ] CSS variables added to `globals.css` if component needs custom styling +- [ ] Exported from `index.ts` with types +- [ ] TypeScript types properly exported +- [ ] Error message display included if applicable +- [ ] Disabled state supported if applicable + diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index fa597f552f..0b16b26f64 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -1,8 +1,11 @@ import type { StorybookConfig } from "@storybook/react-vite"; import { createRequire } from "module"; -import { dirname, join } from "path"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; const require = createRequire(import.meta.url); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); /** * This function is used to resolve the absolute path of a package. @@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any { } const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"], + stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], addons: [ getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-links"), @@ -25,5 +28,25 @@ const config: StorybookConfig = { name: getAbsolutePath("@storybook/react-vite"), options: {}, }, + async viteFinal(config) { + const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src"); + const rootPath = resolve(__dirname, "../../../"); + + // Configure server to allow files from outside the storybook directory + config.server = config.server || {}; + config.server.fs = { + ...config.server.fs, + allow: [...(config.server.fs?.allow || []), rootPath], + }; + + // Configure simple alias resolution + config.resolve = config.resolve || {}; + config.resolve.alias = { + ...config.resolve.alias, + "@": surveyUiPath, + }; + + return config; + }, }; export default config; diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index b0c7224444..58c0444d37 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -1,19 +1,6 @@ import type { Preview } from "@storybook/react-vite"; import React from "react"; -import { I18nProvider } from "../../web/lingodotdev/client"; -import "../../web/modules/ui/globals.css"; - -// Create a Storybook-specific Lingodot Dev decorator -const withLingodotDev = (Story: any) => { - return React.createElement( - I18nProvider, - { - language: "en-US", - defaultLanguage: "en-US", - } as any, - React.createElement(Story) - ); -}; +import "../../../packages/survey-ui/src/styles/globals.css"; const preview: Preview = { parameters: { @@ -22,9 +9,23 @@ const preview: Preview = { color: /(background|color)$/i, date: /Date$/i, }, + expanded: true, + }, + backgrounds: { + default: "light", }, }, - decorators: [withLingodotDev], + decorators: [ + (Story) => + React.createElement( + "div", + { + id: "fbjs", + className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground", + }, + React.createElement(Story) + ), + ], }; export default preview; diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 85ff2a6110..5b32e7d321 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -11,22 +11,24 @@ "clean": "rimraf .turbo node_modules dist storybook-static" }, "dependencies": { - "eslint-plugin-react-refresh": "0.4.20" + "@formbricks/survey-ui": "workspace:*", + "eslint-plugin-react-refresh": "0.4.24" }, "devDependencies": { - "@chromatic-com/storybook": "^4.0.1", - "@storybook/addon-a11y": "9.0.15", - "@storybook/addon-links": "9.0.15", - "@storybook/addon-onboarding": "9.0.15", - "@storybook/react-vite": "9.0.15", - "@typescript-eslint/eslint-plugin": "8.32.0", - "@typescript-eslint/parser": "8.32.0", - "@vitejs/plugin-react": "4.4.1", - "esbuild": "0.25.4", - "eslint-plugin-storybook": "9.0.15", + "@chromatic-com/storybook": "^4.1.3", + "@storybook/addon-a11y": "10.0.8", + "@storybook/addon-links": "10.0.8", + "@storybook/addon-onboarding": "10.0.8", + "@storybook/react-vite": "10.0.8", + "@typescript-eslint/eslint-plugin": "8.48.0", + "@tailwindcss/vite": "4.1.17", + "@typescript-eslint/parser": "8.48.0", + "@vitejs/plugin-react": "5.1.1", + "esbuild": "0.27.0", + "eslint-plugin-storybook": "10.0.8", "prop-types": "15.8.1", - "storybook": "9.0.15", - "vite": "6.4.1", - "@storybook/addon-docs": "9.0.15" + "storybook": "10.0.8", + "vite": "7.2.4", + "@storybook/addon-docs": "10.0.8" } } diff --git a/apps/storybook/postcss.config.js b/apps/storybook/postcss.config.js deleted file mode 100644 index 2aa7205d4b..0000000000 --- a/apps/storybook/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/apps/storybook/tailwind.config.js b/apps/storybook/tailwind.config.js index 574e3b7b54..3b65545a60 100644 --- a/apps/storybook/tailwind.config.js +++ b/apps/storybook/tailwind.config.js @@ -1,7 +1,15 @@ /** @type {import('tailwindcss').Config} */ -import base from "../web/tailwind.config"; +import surveyUi from "../../packages/survey-ui/tailwind.config"; export default { - ...base, - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"], + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + "../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + ...surveyUi.theme?.extend, + }, + }, }; diff --git a/apps/storybook/vite.config.ts b/apps/storybook/vite.config.ts index 37337dae71..f4068dd20b 100644 --- a/apps/storybook/vite.config.ts +++ b/apps/storybook/vite.config.ts @@ -1,16 +1,17 @@ +import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import path from "path"; import { defineConfig } from "vite"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], define: { "process.env": {}, }, resolve: { alias: { - "@": path.resolve(__dirname, "../web"), + "@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"), }, }, }); diff --git a/apps/web/modules/email/emails/lib/utils.tsx b/apps/web/modules/email/emails/lib/utils.tsx index 13903ce0aa..3371ad1d26 100644 --- a/apps/web/modules/email/emails/lib/utils.tsx +++ b/apps/web/modules/email/emails/lib/utils.tsx @@ -15,7 +15,7 @@ export const renderEmailResponseValue = async ( return ( {overrideFileUploadResponse ? ( - + {t("emails.render_email_response_value_file_upload_response_link_not_included")} ) : ( @@ -65,6 +65,6 @@ export const renderEmailResponseValue = async ( ); default: - return {response}; + return {response}; } }; diff --git a/apps/web/modules/email/emails/survey/response-finished-email.tsx b/apps/web/modules/email/emails/survey/response-finished-email.tsx index 73688ad89d..89afb04852 100644 --- a/apps/web/modules/email/emails/survey/response-finished-email.tsx +++ b/apps/web/modules/email/emails/survey/response-finished-email.tsx @@ -74,7 +74,7 @@ export async function ResponseFinishedEmail({ )} {variable.name} - + {variableResponse} @@ -94,7 +94,7 @@ export async function ResponseFinishedEmail({ {hiddenFieldId} - + {hiddenFieldResponse} diff --git a/apps/web/modules/survey/editor/components/block-card.tsx b/apps/web/modules/survey/editor/components/block-card.tsx index d6426a6432..60e911019a 100644 --- a/apps/web/modules/survey/editor/components/block-card.tsx +++ b/apps/web/modules/survey/editor/components/block-card.tsx @@ -284,7 +284,7 @@ export const BlockCard = ({ diff --git a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx index 02caec3a75..3195a1194e 100644 --- a/apps/web/modules/survey/editor/components/survey-menu-bar.tsx +++ b/apps/web/modules/survey/editor/components/survey-menu-bar.tsx @@ -400,7 +400,7 @@ export const SurveyMenuBar = ({ /> -
+
{!isStorageConfigured && (
diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx index e3aecc0795..62eb62834f 100644 --- a/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-email.tsx @@ -84,7 +84,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise - + {variableResponse} @@ -107,7 +107,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise {t("emails.hidden_field")}: {hiddenFieldId} - + {hiddenFieldResponse} diff --git a/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx b/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx index 9e6c07908f..c7a088f6ea 100644 --- a/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx +++ b/apps/web/modules/survey/follow-ups/components/follow-up-item.tsx @@ -155,7 +155,7 @@ export const FollowUpItem = ({
-
+
+ ); +} +``` + +## Development + +```bash +# Build the package +pnpm build + +# Watch mode for development +pnpm dev + + +# Lint +pnpm lint +``` + +## Structure + +```text +src/ +├── components/ # React components +├── lib/ # Utility functions +└── index.ts # Main entry point +``` + +## Adding New Components + +### Using shadcn CLI (Recommended) + +This package is configured to work with shadcn/ui CLI. You can add components using: + +```bash +cd packages/survey-ui +pnpm ui:add +``` + +**Important**: After adding a component, reorganize it into a folder structure: + +For example: +```bash +pnpm ui:add button +pnpm ui:organize button +``` + +Then export the component from `src/components/index.ts`. + +### Manual Component Creation + +1. Create a new component directory under `src/components//` +2. Create `index.tsx` inside that directory +3. Export the component from `src/components/index.ts` +4. The component will be available from the main package export + +## Component Structure + +Components follow this folder structure: + +```text +src/components/ +├── button.tsx +├── button.stories.tsx +``` + +## Theming + +This package uses CSS variables for theming. The theme can be customized by modifying `src/styles/globals.css`. + +Both light and dark modes are supported out of the box. + +## CSS Scoping + +By default, this package builds CSS scoped to `#fbjs` for use in the surveys package. This ensures proper specificity and prevents conflicts with preflight CSS. + +To build unscoped CSS (e.g., for standalone usage or Storybook), set the `SURVEY_UI_UNSCOPED` environment variable: + +```bash +SURVEY_UI_UNSCOPED=true pnpm build +``` + +**Note:** Storybook imports the source CSS directly and compiles it with its own Tailwind config, so it's not affected by this scoping setting. + diff --git a/packages/survey-ui/components.json b/packages/survey-ui/components.json new file mode 100644 index 0000000000..524a1d8409 --- /dev/null +++ b/packages/survey-ui/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "ui": "@/components", + "utils": "@/lib/utils" + }, + "rsc": false, + "style": "new-york", + "tailwind": { + "baseColor": "slate", + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "cssVariables": true, + "prefix": "" + }, + "tsx": true +} diff --git a/packages/survey-ui/package.json b/packages/survey-ui/package.json new file mode 100644 index 0000000000..9c50389f12 --- /dev/null +++ b/packages/survey-ui/package.json @@ -0,0 +1,80 @@ +{ + "name": "@formbricks/survey-ui", + "license": "MIT", + "version": "1.0.0", + "private": true, + "description": "Reusable UI components for Formbricks applications", + "homepage": "https://formbricks.com", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/formbricks/formbricks" + }, + "sideEffects": false, + "source": "src/index.ts", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./styles": "./dist/survey-ui.css" + }, + "scripts": { + "dev": "vite build --watch --mode dev", + "build": "vite build", + "build:dev": "vite build --mode dev", + "go": "vite build --watch --mode dev", + "lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx", + "preview": "vite preview", + "clean": "rimraf .turbo node_modules dist coverage", + "ui:add": "npx shadcn@latest add", + "test": "vitest", + "test:coverage": "vitest run --coverage" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "dependencies": { + "@formkit/auto-animate": "0.8.2", + "@radix-ui/react-checkbox": "1.3.1", + "@radix-ui/react-dropdown-menu": "2.1.14", + "@radix-ui/react-popover": "1.1.13", + "@radix-ui/react-progress": "1.1.8", + "@radix-ui/react-radio-group": "1.3.6", + "@radix-ui/react-slot": "1.2.2", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "date-fns": "4.1.0", + "isomorphic-dompurify": "2.33.0", + "lucide-react": "0.507.0", + "react-day-picker": "9.6.7", + "tailwind-merge": "3.2.0" + }, + "devDependencies": { + "@formbricks/config-typescript": "workspace:*", + "@formbricks/eslint-config": "workspace:*", + "@storybook/react": "8.5.4", + "@storybook/react-vite": "8.5.4", + "@tailwindcss/postcss": "4.0.0", + "@tailwindcss/vite": "4.1.17", + "@types/react": "19.2.1", + "@types/react-dom": "19.2.1", + "@vitejs/plugin-react": "4.3.4", + "react": "19.2.1", + "react-dom": "19.2.1", + "rimraf": "6.0.1", + "tailwindcss": "4.1.1", + "vite": "6.4.1", + "vite-plugin-dts": "4.5.3", + "vite-tsconfig-paths": "5.1.4", + "@vitest/coverage-v8": "3.1.3", + "vitest": "3.1.3" + } +} diff --git a/packages/survey-ui/postcss.config.mjs b/packages/survey-ui/postcss.config.mjs new file mode 100644 index 0000000000..27ae1c90b5 --- /dev/null +++ b/packages/survey-ui/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; \ No newline at end of file diff --git a/packages/survey-ui/src/components/elements/consent.stories.tsx b/packages/survey-ui/src/components/elements/consent.stories.tsx new file mode 100644 index 0000000000..ecc0e8288f --- /dev/null +++ b/packages/survey-ui/src/components/elements/consent.stories.tsx @@ -0,0 +1,188 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + type BaseStylingOptions, + commonArgTypes, + createCSSVariablesDecorator, + createStatefulRender, + elementStylingArgTypes, + inputStylingArgTypes, + pickArgTypes, + surveyStylingArgTypes, +} from "../../lib/story-helpers"; +import { Consent, type ConsentProps } from "./consent"; + +type StoryProps = ConsentProps & Partial & Record; + +const meta: Meta = { + title: "UI-package/Elements/Consent", + component: Consent, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A consent element that displays a checkbox for users to accept terms, conditions, or agreements.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + ...commonArgTypes, + checkboxLabel: { + control: "text", + description: "Label text for the consent checkbox", + table: { category: "Content" }, + }, + value: { + control: "boolean", + description: "Whether consent is checked", + table: { category: "State" }, + }, + }, + render: createStatefulRender(Consent), +}; + +export default meta; +type Story = StoryObj; + +export const StylingPlayground: Story = { + args: { + elementId: "consent-1", + inputId: "consent-input-1", + headline: "Terms and Conditions", + description: "Please read and accept the terms", + checkboxLabel: "I agree to the terms and conditions", + onChange: () => {}, + }, + argTypes: { + ...elementStylingArgTypes, + ...pickArgTypes(inputStylingArgTypes, [ + "inputBgColor", + "inputBorderColor", + "inputColor", + "inputFontSize", + "inputFontWeight", + "inputWidth", + "inputBorderRadius", + "inputPaddingX", + "inputPaddingY", + ]), + ...surveyStylingArgTypes, + }, + decorators: [createCSSVariablesDecorator()], +}; + +export const Default: Story = { + args: { + elementId: "consent-1", + inputId: "consent-input-1", + headline: "Terms and Conditions", + checkboxLabel: "I agree to the terms and conditions", + onChange: () => {}, + }, +}; + +export const WithDescription: Story = { + args: { + elementId: "consent-2", + inputId: "consent-input-2", + headline: "Terms and Conditions", + description: "Please read and accept the terms to continue", + checkboxLabel: "I agree to the terms and conditions", + onChange: () => {}, + }, +}; + +export const WithConsent: Story = { + args: { + elementId: "consent-3", + inputId: "consent-input-3", + headline: "Terms and Conditions", + checkboxLabel: "I agree to the terms and conditions", + value: true, + onChange: () => {}, + }, +}; + +export const Required: Story = { + args: { + elementId: "consent-4", + inputId: "consent-input-4", + headline: "Terms and Conditions", + checkboxLabel: "I agree to the terms and conditions", + required: true, + onChange: () => {}, + }, +}; + +export const WithError: Story = { + args: { + elementId: "consent-5", + inputId: "consent-input-5", + headline: "Terms and Conditions", + checkboxLabel: "I agree to the terms and conditions", + required: true, + errorMessage: "You must accept the terms to continue", + onChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + elementId: "consent-6", + inputId: "consent-input-6", + headline: "Terms and Conditions", + checkboxLabel: "I agree to the terms and conditions", + value: true, + disabled: true, + onChange: () => {}, + }, +}; + +export const RTL: Story = { + args: { + elementId: "consent-rtl", + inputId: "consent-input-rtl", + headline: "الشروط والأحكام", + description: "يرجى قراءة الشروط والموافقة عليها", + checkboxLabel: "أوافق على الشروط والأحكام", + dir: "rtl", + onChange: () => {}, + }, +}; + +export const RTLWithConsent: Story = { + args: { + elementId: "consent-rtl-checked", + inputId: "consent-input-rtl-checked", + headline: "الشروط والأحكام", + checkboxLabel: "أوافق على الشروط والأحكام", + value: true, + dir: "rtl", + onChange: () => {}, + }, +}; + +export const MultipleElements: Story = { + render: () => ( +
+ {}} + /> + {}} + /> +
+ ), +}; diff --git a/packages/survey-ui/src/components/elements/consent.tsx b/packages/survey-ui/src/components/elements/consent.tsx new file mode 100644 index 0000000000..7cf7bab4f8 --- /dev/null +++ b/packages/survey-ui/src/components/elements/consent.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; +import { Checkbox } from "@/components/general/checkbox"; +import { ElementError } from "@/components/general/element-error"; +import { ElementHeader } from "@/components/general/element-header"; +import { cn } from "@/lib/utils"; + +/** + * Props for the Consent element component + */ +export interface ConsentProps { + /** Unique identifier for the element container */ + elementId: string; + /** The main element or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Unique identifier for the consent checkbox */ + inputId: string; + /** Label text for the consent checkbox */ + checkboxLabel: string; + /** Whether consent is checked */ + value?: boolean; + /** Callback function called when consent changes */ + onChange: (checked: boolean) => void; + /** Whether the field is required (shows asterisk indicator) */ + required?: boolean; + /** Error message to display */ + errorMessage?: string; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: "ltr" | "rtl" | "auto"; + /** Whether the checkbox is disabled */ + disabled?: boolean; +} + +function Consent({ + elementId, + headline, + description, + inputId, + checkboxLabel, + value = false, + onChange, + required = false, + errorMessage, + dir = "auto", + disabled = false, +}: Readonly): React.JSX.Element { + const handleCheckboxChange = (checked: boolean): void => { + if (disabled) return; + onChange(checked); + }; + + return ( +
+ {/* Headline */} + + + {/* Consent Checkbox */} +
+ + + +
+
+ ); +} + +export { Consent }; diff --git a/packages/survey-ui/src/components/elements/cta.stories.tsx b/packages/survey-ui/src/components/elements/cta.stories.tsx new file mode 100644 index 0000000000..874601d034 --- /dev/null +++ b/packages/survey-ui/src/components/elements/cta.stories.tsx @@ -0,0 +1,186 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + type BaseStylingOptions, + type ButtonStylingOptions, + buttonStylingArgTypes, + commonArgTypes, + createCSSVariablesDecorator, + elementStylingArgTypes, +} from "../../lib/story-helpers"; +import { CTA, type CTAProps } from "./cta"; + +type StoryProps = CTAProps & Partial & Record; + +const meta: Meta = { + title: "UI-package/Elements/CTA", + component: CTA, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A Call-to-Action (CTA) element that displays a button. Can optionally open an external URL when clicked.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + ...commonArgTypes, + buttonLabel: { + control: "text", + description: "Label text for the CTA button", + table: { category: "Content" }, + }, + buttonUrl: { + control: "text", + description: "URL to open when button is clicked (if external)", + table: { category: "Content" }, + }, + buttonExternal: { + control: "boolean", + description: "Whether the button opens an external URL", + table: { category: "Content" }, + }, + buttonVariant: { + control: "select", + options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"], + description: "Variant for the button. Must be 'custom' for button styling controls to work.", + table: { category: "Button Styling (Only applicable when buttonVariant is 'custom')" }, + }, + onClick: { + action: () => { + alert("clicked"); + }, + table: { category: "Events" }, + }, + ...elementStylingArgTypes, + ...buttonStylingArgTypes, + }, + decorators: [createCSSVariablesDecorator()], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + elementId: "cta-1", + inputId: "cta-input-1", + headline: "Ready to get started?", + buttonLabel: "Get Started", + onClick: () => { + alert("clicked"); + }, + }, +}; + +export const WithDescription: Story = { + args: { + elementId: "cta-2", + inputId: "cta-input-2", + headline: "Ready to get started?", + description: "Click the button below to begin your journey", + buttonLabel: "Get Started", + onClick: () => { + alert("clicked"); + }, + }, +}; + +export const ExternalButton: Story = { + args: { + elementId: "cta-3", + inputId: "cta-input-3", + headline: "Learn more about us", + description: "Visit our website to learn more", + buttonLabel: "Visit Website", + buttonUrl: "https://example.com", + buttonExternal: true, + onClick: () => { + alert("clicked"); + }, + }, +}; + +export const Required: Story = { + args: { + elementId: "cta-4", + inputId: "cta-input-4", + headline: "Ready to get started?", + buttonLabel: "Get Started", + required: true, + onClick: () => { + alert("clicked"); + }, + }, +}; + +export const WithError: Story = { + args: { + elementId: "cta-5", + inputId: "cta-input-5", + headline: "Ready to get started?", + buttonLabel: "Get Started", + required: true, + errorMessage: "Please click the button to continue", + onClick: () => { + alert("clicked"); + }, + }, +}; + +export const Disabled: Story = { + args: { + elementId: "cta-6", + inputId: "cta-input-6", + headline: "Ready to get started?", + buttonLabel: "Get Started", + disabled: true, + onClick: () => { + alert("clicked"); + }, + }, +}; + +export const RTL: Story = { + args: { + elementId: "cta-rtl", + inputId: "cta-input-rtl", + headline: "هل أنت مستعد للبدء؟", + description: "انقر على الزر أدناه للبدء", + buttonLabel: "ابدأ الآن", + dir: "rtl", + onClick: () => { + alert("clicked"); + }, + }, +}; + +export const MultipleElements: Story = { + render: () => ( +
+ { + alert("clicked"); + }} + /> + { + alert("clicked"); + }} + /> +
+ ), +}; diff --git a/packages/survey-ui/src/components/elements/cta.tsx b/packages/survey-ui/src/components/elements/cta.tsx new file mode 100644 index 0000000000..46bcc8df74 --- /dev/null +++ b/packages/survey-ui/src/components/elements/cta.tsx @@ -0,0 +1,89 @@ +import { SquareArrowOutUpRightIcon } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/general/button"; +import { ElementError } from "@/components/general/element-error"; +import { ElementHeader } from "@/components/general/element-header"; + +/** + * Props for the CTA (Call to Action) element component + */ +export interface CTAProps { + /** Unique identifier for the element container */ + elementId: string; + /** The main element or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Unique identifier for the CTA button */ + inputId: string; + /** Label text for the CTA button */ + buttonLabel: string; + /** URL to open when button is clicked (if external button) */ + buttonUrl?: string; + /** Whether the button opens an external URL */ + buttonExternal?: boolean; + /** Callback function called when button is clicked */ + onClick: () => void; + /** Whether the field is required (shows asterisk indicator) */ + required?: boolean; + /** Error message to display */ + errorMessage?: string; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: "ltr" | "rtl" | "auto"; + /** Whether the button is disabled */ + disabled?: boolean; + /** Variant for the button */ + buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom"; +} + +function CTA({ + elementId, + headline, + description, + inputId, + buttonLabel, + buttonUrl, + buttonExternal = false, + onClick, + required = false, + errorMessage, + dir = "auto", + disabled = false, + buttonVariant = "default", +}: Readonly): React.JSX.Element { + const handleButtonClick = (): void => { + if (disabled) return; + onClick(); + + if (buttonExternal && buttonUrl) { + window.open(buttonUrl, "_blank")?.focus(); + } + }; + + return ( +
+ {/* Headline */} + + + {/* CTA Button */} +
+ + +
+ +
+
+
+ ); +} + +export { CTA }; diff --git a/packages/survey-ui/src/components/elements/date.stories.tsx b/packages/survey-ui/src/components/elements/date.stories.tsx new file mode 100644 index 0000000000..1392629b22 --- /dev/null +++ b/packages/survey-ui/src/components/elements/date.stories.tsx @@ -0,0 +1,315 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + type BaseStylingOptions, + type InputLayoutStylingOptions, + commonArgTypes, + createCSSVariablesDecorator, + createStatefulRender, + elementStylingArgTypes, + surveyStylingArgTypes, +} from "../../lib/story-helpers"; +import { DateElement, type DateElementProps } from "./date"; + +type StoryProps = DateElementProps & + Partial> & + Record; + +const meta: Meta = { + title: "UI-package/Elements/Date", + component: DateElement, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A complete date element that combines headline, description, and a date input. Supports date range constraints, validation, and RTL text direction.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + ...commonArgTypes, + value: { + control: "text", + description: "Current date value in ISO format (YYYY-MM-DD)", + table: { category: "State" }, + }, + minDate: { + control: "text", + description: "Minimum date allowed (ISO format: YYYY-MM-DD)", + table: { category: "Validation" }, + }, + maxDate: { + control: "text", + description: "Maximum date allowed (ISO format: YYYY-MM-DD)", + table: { category: "Validation" }, + }, + locale: { + control: { type: "select" }, + options: [ + "en", + "de", + "fr", + "es", + "ja", + "pt", + "pt-BR", + "ro", + "zh-Hans", + "zh-Hant", + "nl", + "ar", + "it", + "ru", + "uz", + "hi", + ], + description: "Locale code for date formatting (survey language codes: 'en', 'de', 'ar', etc.)", + table: { category: "Localization" }, + }, + }, + render: createStatefulRender(DateElement), +}; + +export default meta; +type Story = StoryObj; + +export const StylingPlayground: Story = { + args: { + headline: "What is your date of birth?", + description: "Please select a date", + }, + argTypes: { + ...elementStylingArgTypes, + ...surveyStylingArgTypes, + inputBgColor: { + control: "color", + table: { category: "Input Styling" }, + }, + inputBorderColor: { + control: "color", + table: { category: "Input Styling" }, + }, + inputColor: { + control: "color", + table: { category: "Input Styling" }, + }, + inputBorderRadius: { + control: "text", + table: { category: "Input Styling" }, + }, + }, + decorators: [createCSSVariablesDecorator()], +}; + +export const Default: Story = { + args: { + headline: "What is your date of birth?", + }, +}; + +export const WithDescription: Story = { + args: { + headline: "When would you like to schedule the appointment?", + description: "Please select a date for your appointment", + }, +}; + +export const Required: Story = { + args: { + headline: "What is your date of birth?", + description: "Please select your date of birth", + required: true, + }, +}; + +export const WithValue: Story = { + args: { + headline: "What is your date of birth?", + value: "1990-01-15", + }, +}; + +export const WithDateRange: Story = { + args: { + headline: "Select a date for your event", + description: "Please choose a date between today and next year", + minDate: new Date().toISOString().split("T")[0], + maxDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split("T")[0], + }, +}; + +export const WithError: Story = { + args: { + headline: "What is your date of birth?", + description: "Please select your date of birth", + errorMessage: "Please select a valid date", + required: true, + }, +}; + +export const Disabled: Story = { + args: { + headline: "This date field is disabled", + description: "You cannot change the date", + value: "2024-01-15", + disabled: true, + }, +}; + +export const PastDatesOnly: Story = { + args: { + headline: "When did you start your current job?", + description: "Select a date in the past", + maxDate: new Date().toISOString().split("T")[0], + }, +}; + +export const FutureDatesOnly: Story = { + args: { + headline: "When would you like to schedule the meeting?", + description: "Select a date in the future", + minDate: new Date().toISOString().split("T")[0], + }, +}; + +export const RTL: Story = { + args: { + headline: "ما هو تاريخ ميلادك؟", + description: "يرجى اختيار تاريخ", + dir: "rtl", + }, +}; + +export const RTLWithValue: Story = { + args: { + headline: "ما هو تاريخ ميلادك؟", + description: "يرجى اختيار تاريخ", + value: "1990-01-15", + dir: "rtl", + }, +}; + +export const MultipleElements: Story = { + render: () => ( +
+ {}} + /> + {}} + /> +
+ ), +}; + +export const WithLocale: Story = { + args: { + headline: "What is your date of birth?", + description: "Date picker with locale-specific formatting", + locale: "en", + }, +}; + +export const LocaleExamples: Story = { + render: () => ( +
+
+

English (en)

+ {}} + /> +
+
+

German (de)

+ {}} + /> +
+
+

French (fr)

+ {}} + /> +
+
+

Spanish (es)

+ {}} + /> +
+
+

Japanese (ja)

+ {}} + /> +
+
+

Arabic (ar)

+ {}} + /> +
+
+

Russian (ru)

+ {}} + /> +
+
+

Chinese Simplified (zh-Hans)

+ {}} + /> +
+
+ ), +}; diff --git a/packages/survey-ui/src/components/elements/date.tsx b/packages/survey-ui/src/components/elements/date.tsx new file mode 100644 index 0000000000..d00c2e6e90 --- /dev/null +++ b/packages/survey-ui/src/components/elements/date.tsx @@ -0,0 +1,148 @@ +import * as React from "react"; +import { Calendar } from "@/components/general/calendar"; +import { ElementHeader } from "@/components/general/element-header"; +import { getDateFnsLocale } from "@/lib/locale"; + +interface DateElementProps { + /** Unique identifier for the element container */ + elementId: string; + /** The main element or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Unique identifier for the date input */ + inputId: string; + /** Current date value in ISO format (YYYY-MM-DD) */ + value?: string; + /** Callback function called when the date value changes */ + onChange: (value: string) => void; + /** Whether the field is required (shows asterisk indicator) */ + required?: boolean; + /** Minimum date allowed (ISO format: YYYY-MM-DD) */ + minDate?: string; + /** Maximum date allowed (ISO format: YYYY-MM-DD) */ + maxDate?: string; + /** Error message to display */ + errorMessage?: string; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: "ltr" | "rtl" | "auto"; + /** Whether the date input is disabled */ + disabled?: boolean; + /** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */ + locale?: string; +} + +function DateElement({ + elementId, + headline, + description, + inputId, + value, + onChange, + required = false, + minDate, + maxDate, + dir = "auto", + disabled = false, + locale = "en-US", +}: Readonly): React.JSX.Element { + // Initialize date from value string, parsing as local time to avoid timezone issues + const [date, setDate] = React.useState(() => { + if (!value) return undefined; + // Parse YYYY-MM-DD format as local date (not UTC) + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day); + }); + + // Sync date state when value prop changes + React.useEffect(() => { + if (value) { + // Parse YYYY-MM-DD format as local date (not UTC) + const [year, month, day] = value.split("-").map(Number); + const newDate = new Date(year, month - 1, day); + setDate((prevDate) => { + // Only update if the date actually changed to avoid unnecessary re-renders + if (!prevDate || newDate.getTime() !== prevDate.getTime()) { + return newDate; + } + return prevDate; + }); + } else { + setDate(undefined); + } + }, [value]); + + // Convert Date to ISO string (YYYY-MM-DD) when date changes + const handleDateSelect = (selectedDate: Date | undefined): void => { + setDate(selectedDate); + if (selectedDate) { + // Convert to ISO format (YYYY-MM-DD) using local time to avoid timezone issues + const year = String(selectedDate.getFullYear()); + const month = String(selectedDate.getMonth() + 1).padStart(2, "0"); + const day = String(selectedDate.getDate()).padStart(2, "0"); + const isoString = `${year}-${month}-${day}`; + onChange(isoString); + } else { + onChange(""); + } + }; + + // Convert minDate/maxDate strings to Date objects + const minDateObj = minDate ? new Date(minDate) : undefined; + const maxDateObj = maxDate ? new Date(maxDate) : undefined; + + // Create disabled function for date restrictions + const isDateDisabled = React.useCallback( + (dateToCheck: Date): boolean => { + if (disabled) return true; + if (minDateObj) { + const minAtMidnight = new Date(minDateObj.getFullYear(), minDateObj.getMonth(), minDateObj.getDate()); + const checkAtMidnight = new Date( + dateToCheck.getFullYear(), + dateToCheck.getMonth(), + dateToCheck.getDate() + ); + if (checkAtMidnight < minAtMidnight) return true; + } + if (maxDateObj) { + const maxAtMidnight = new Date(maxDateObj.getFullYear(), maxDateObj.getMonth(), maxDateObj.getDate()); + const checkAtMidnight = new Date( + dateToCheck.getFullYear(), + dateToCheck.getMonth(), + dateToCheck.getDate() + ); + if (checkAtMidnight > maxAtMidnight) return true; + } + return false; + }, + [disabled, minDateObj, maxDateObj] + ); + + // Get locale for date formatting + const dateLocale = React.useMemo(() => { + return locale ? getDateFnsLocale(locale) : undefined; + }, [locale]); + + return ( +
+ {/* Headline */} + + + {/* Calendar - Always visible */} +
+ +
+
+ ); +} + +export { DateElement }; +export type { DateElementProps }; diff --git a/packages/survey-ui/src/components/elements/file-upload.stories.tsx b/packages/survey-ui/src/components/elements/file-upload.stories.tsx new file mode 100644 index 0000000000..9658b404b0 --- /dev/null +++ b/packages/survey-ui/src/components/elements/file-upload.stories.tsx @@ -0,0 +1,244 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useEffect, useState } from "react"; +import { + type BaseStylingOptions, + type InputLayoutStylingOptions, + commonArgTypes, + createCSSVariablesDecorator, + elementStylingArgTypes, + inputStylingArgTypes, + pickArgTypes, +} from "../../lib/story-helpers"; +import { FileUpload, type FileUploadProps, type UploadedFile } from "./file-upload"; + +type StoryProps = FileUploadProps & + Partial & + Record; + +const meta: Meta = { + title: "UI-package/Elements/FileUpload", + component: FileUpload, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A complete file upload element that combines headline, description, and a file upload area with drag-and-drop support. Supports file type restrictions, size limits, multiple files, validation, and RTL text direction.", + }, + }, + }, + tags: ["autodocs"], + decorators: [createCSSVariablesDecorator()], + argTypes: { + ...commonArgTypes, + value: { + control: "object", + description: "Array of uploaded files", + table: { category: "State" }, + }, + allowMultiple: { + control: "boolean", + description: "Whether multiple files are allowed", + table: { category: "Behavior" }, + }, + maxSizeInMB: { + control: "number", + description: "Maximum file size in MB", + table: { category: "Validation" }, + }, + allowedFileExtensions: { + control: "object", + description: "Allowed file extensions (e.g., ['.pdf', '.jpg'])", + table: { category: "Validation" }, + }, + ...elementStylingArgTypes, + ...pickArgTypes(inputStylingArgTypes, [ + "inputBgColor", + "inputBorderColor", + "inputColor", + "inputFontSize", + "inputFontWeight", + "inputWidth", + "inputHeight", + "inputBorderRadius", + "inputPaddingX", + "inputPaddingY", + ]), + }, + render: function Render(args: StoryProps) { + const [value, setValue] = useState(args.value); + + useEffect(() => { + setValue(args.value); + }, [args.value]); + + return ( + { + setValue(v); + args.onChange?.(v); + }} + /> + ); + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + headline: "Upload your file", + }, +}; + +export const WithDescription: Story = { + args: { + headline: "Upload your resume", + description: "Please upload your resume in PDF format", + }, +}; + +export const SingleFile: Story = { + args: { + headline: "Upload a single file", + description: "Select one file to upload", + allowMultiple: false, + }, +}; + +export const MultipleFiles: Story = { + args: { + headline: "Upload multiple files", + description: "You can upload multiple files at once", + allowMultiple: true, + }, +}; + +export const WithFileTypeRestrictions: Story = { + args: { + headline: "Upload an image", + description: "Please upload an image file", + allowedFileExtensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"], + }, +}; + +export const WithSizeLimit: Story = { + args: { + headline: "Upload a document", + description: "Maximum file size: 5MB", + maxSizeInMB: 5, + }, +}; + +export const WithRestrictions: Story = { + args: { + headline: "Upload a PDF document", + description: "PDF files only, maximum 10MB", + allowedFileExtensions: [".pdf"], + maxSizeInMB: 10, + }, +}; + +export const Required: Story = { + args: { + headline: "Upload required file", + description: "Please upload a file", + required: true, + }, +}; + +export const WithUploadedFiles: Story = { + args: { + headline: "Upload your files", + description: "Files you've uploaded", + allowMultiple: true, + value: [ + { + name: "document.pdf", + url: "data:application/pdf;base64,...", + size: 1024 * 500, // 500 KB + }, + { + name: "image.jpg", + url: "data:image/jpeg;base64,...", + size: 1024 * 1024 * 2, // 2 MB + }, + ] as UploadedFile[], + }, +}; + +export const WithError: Story = { + args: { + headline: "Upload your file", + description: "Please upload a file", + errorMessage: "Please upload at least one file", + required: true, + }, +}; + +export const Disabled: Story = { + args: { + headline: "This upload is disabled", + description: "You cannot upload files", + value: [ + { + name: "existing-file.pdf", + url: "data:application/pdf;base64,...", + size: 1024 * 300, + }, + ] as UploadedFile[], + disabled: true, + }, +}; + +export const RTL: Story = { + args: { + headline: "قم بتحميل ملفك", + description: "يرجى اختيار ملف للتحميل", + dir: "rtl", + }, +}; + +export const RTLWithFiles: Story = { + args: { + headline: "قم بتحميل ملفاتك", + description: "الملفات التي قمت بتحميلها", + allowMultiple: true, + value: [ + { + name: "ملف.pdf", + url: "data:application/pdf;base64,...", + size: 1024 * 500, + }, + ] as UploadedFile[], + dir: "rtl", + }, +}; + +export const MultipleElements: Story = { + render: () => ( +
+ {}} + /> + {}} + /> +
+ ), +}; diff --git a/packages/survey-ui/src/components/elements/file-upload.tsx b/packages/survey-ui/src/components/elements/file-upload.tsx new file mode 100644 index 0000000000..2674b7c382 --- /dev/null +++ b/packages/survey-ui/src/components/elements/file-upload.tsx @@ -0,0 +1,336 @@ +import { Upload, UploadIcon, X } from "lucide-react"; +import * as React from "react"; +import { ElementError } from "@/components/general/element-error"; +import { ElementHeader } from "@/components/general/element-header"; +import { Input } from "@/components/general/input"; +import { cn } from "@/lib/utils"; + +/** + * Uploaded file information + */ +export interface UploadedFile { + /** File name */ + name: string; + /** File URL or data URL */ + url: string; + /** File size in bytes */ + size?: number; +} + +interface FileUploadProps { + /** Unique identifier for the element container */ + elementId: string; + /** The main element or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Unique identifier for the file input */ + inputId: string; + /** Currently uploaded files */ + value?: UploadedFile[]; + /** Callback function called when files change */ + onChange: (files: UploadedFile[]) => void; + /** Callback function called when files are selected (before validation) */ + onFileSelect?: (files: FileList) => void; + /** Whether multiple files are allowed */ + allowMultiple?: boolean; + /** Allowed file extensions (e.g., ['.pdf', '.jpg', '.png']) */ + allowedFileExtensions?: string[]; + /** Whether the field is required (shows asterisk indicator) */ + required?: boolean; + /** Error message to display */ + errorMessage?: string; + /** Whether the component is in uploading state */ + isUploading?: boolean; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: "ltr" | "rtl" | "auto"; + /** Whether the file input is disabled */ + disabled?: boolean; + /** Image URL to display above the headline */ + imageUrl?: string; + /** Video URL to display above the headline */ + videoUrl?: string; + /** Alt text for the image */ + imageAltText?: string; + /** Placeholder text for the file upload */ + placeholderText?: string; +} + +interface UploadedFileItemProps { + file: UploadedFile; + index: number; + disabled: boolean; + onDelete: (index: number, e: React.MouseEvent) => void; +} + +function UploadedFileItem({ + file, + index, + disabled, + onDelete, +}: Readonly): React.JSX.Element { + return ( +
+
+ +
+
+ +

+ {file.name} +

+
+
+ ); +} + +interface UploadedFilesListProps { + files: UploadedFile[]; + disabled: boolean; + onDelete: (index: number, e: React.MouseEvent) => void; +} + +function UploadedFilesList({ + files, + disabled, + onDelete, +}: Readonly): React.JSX.Element | null { + if (files.length === 0) { + return null; + } + + return ( +
+ {files.map((file, index) => ( + + ))} +
+ ); +} + +interface UploadAreaProps { + inputId: string; + fileInputRef: React.RefObject; + placeholderText: string; + allowMultiple: boolean; + acceptAttribute?: string; + required: boolean; + disabled: boolean; + dir: "ltr" | "rtl" | "auto"; + onFileChange: (e: React.ChangeEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; + showUploader: boolean; +} + +function UploadArea({ + inputId, + fileInputRef, + placeholderText, + allowMultiple, + acceptAttribute, + required, + disabled, + dir, + onFileChange, + onDragOver, + onDrop, + showUploader, +}: Readonly): React.JSX.Element | null { + if (!showUploader) { + return null; + } + + return ( + + ); +} + +function FileUpload({ + elementId, + headline, + description, + inputId, + value = [], + onChange, + onFileSelect, + allowMultiple = false, + allowedFileExtensions, + required = false, + errorMessage, + isUploading = false, + dir = "auto", + disabled = false, + imageUrl, + videoUrl, + imageAltText, + placeholderText = "Click or drag to upload files", +}: Readonly): React.JSX.Element { + const fileInputRef = React.useRef(null); + + // Ensure value is always an array + const uploadedFiles = Array.isArray(value) ? value : []; + + const handleFileChange = (e: React.ChangeEvent): void => { + if (!e.target.files || disabled) return; + if (onFileSelect) { + onFileSelect(e.target.files); + } + // Reset input to allow selecting the same file again + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + const handleDragOver = (e: React.DragEvent): void => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + }; + + const handleDrop = (e: React.DragEvent): void => { + e.preventDefault(); + e.stopPropagation(); + if (onFileSelect && e.dataTransfer.files.length > 0) { + onFileSelect(e.dataTransfer.files); + } + }; + + const handleDeleteFile = (index: number, e: React.MouseEvent): void => { + e.stopPropagation(); + const updatedFiles = [...uploadedFiles]; + updatedFiles.splice(index, 1); + onChange(updatedFiles); + }; + + // Build accept attribute from allowed extensions + const acceptAttribute = allowedFileExtensions + ?.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)) + .join(","); + + // Show uploader if uploading, or if multiple files allowed, or if no files uploaded yet + const showUploader = isUploading || allowMultiple || uploadedFiles.length === 0; + + return ( +
+ + +
+ + +
+ + +
+ {isUploading ? ( +
+

+ Uploading... +

+
+ ) : null} + + +
+
+
+
+ ); +} + +export { FileUpload }; +export type { FileUploadProps }; diff --git a/packages/survey-ui/src/components/elements/form-field.stories.tsx b/packages/survey-ui/src/components/elements/form-field.stories.tsx new file mode 100644 index 0000000000..d28f455658 --- /dev/null +++ b/packages/survey-ui/src/components/elements/form-field.stories.tsx @@ -0,0 +1,362 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + type BaseStylingOptions, + type InputLayoutStylingOptions, + type LabelStylingOptions, + commonArgTypes, + createCSSVariablesDecorator, + createStatefulRender, +} from "../../lib/story-helpers"; +import { FormField, type FormFieldConfig, type FormFieldProps } from "./form-field"; + +type StoryProps = FormFieldProps & + Partial & + Record; + +const meta: Meta = { + title: "UI-package/Elements/FormField", + component: FormField, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A flexible form field element that can display multiple input fields with different configurations. Replaces Contact Info and Address elements.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + ...commonArgTypes, + fields: { + control: "object", + description: "Array of form field configurations", + table: { category: "Content" }, + }, + value: { + control: "object", + description: "Current values as a record mapping field IDs to their values", + table: { category: "State" }, + }, + }, + render: createStatefulRender(FormField), +}; + +export default meta; +type Story = StoryObj; + +// Decorator to apply CSS variables from story args + +// Contact Info fields preset +const contactInfoFields: FormFieldConfig[] = [ + { id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true }, + { id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true }, + { id: "email", label: "Email", placeholder: "Email", type: "email", required: true, show: true }, + { id: "phone", label: "Phone", placeholder: "Phone", type: "tel", required: true, show: true }, + { id: "company", label: "Company", placeholder: "Company", required: true, show: true }, +]; + +// Address fields preset +const addressFields: FormFieldConfig[] = [ + { id: "addressLine1", label: "Address Line 1", placeholder: "Address Line 1", required: true, show: true }, + { id: "addressLine2", label: "Address Line 2", placeholder: "Address Line 2", required: true, show: true }, + { id: "city", label: "City", placeholder: "City", required: true, show: true }, + { id: "state", label: "State", placeholder: "State", required: true, show: true }, + { id: "zip", label: "Zip", placeholder: "Zip", required: true, show: true }, + { id: "country", label: "Country", placeholder: "Country", required: true, show: true }, +]; + +export const StylingPlayground: Story = { + args: { + elementId: "form-field-1", + headline: "Please provide your contact information", + description: "We'll use this to contact you", + fields: contactInfoFields, + }, + argTypes: { + elementHeadlineFontFamily: { + control: "text", + table: { category: "Element Styling" }, + }, + elementHeadlineFontSize: { + control: "text", + table: { category: "Element Styling" }, + }, + elementHeadlineFontWeight: { + control: "text", + table: { category: "Element Styling" }, + }, + elementHeadlineColor: { + control: "color", + table: { category: "Element Styling" }, + }, + elementDescriptionFontFamily: { + control: "text", + table: { category: "Element Styling" }, + }, + elementDescriptionFontSize: { + control: "text", + table: { category: "Element Styling" }, + }, + elementDescriptionFontWeight: { + control: "text", + table: { category: "Element Styling" }, + }, + elementDescriptionColor: { + control: "color", + table: { category: "Element Styling" }, + }, + labelFontFamily: { + control: "text", + table: { category: "Label Styling" }, + }, + labelFontSize: { + control: "text", + table: { category: "Label Styling" }, + }, + labelFontWeight: { + control: "text", + table: { category: "Label Styling" }, + }, + labelColor: { + control: "color", + table: { category: "Label Styling" }, + }, + inputWidth: { + control: "text", + table: { category: "Input Styling" }, + }, + inputHeight: { + control: "text", + table: { category: "Input Styling" }, + }, + inputBgColor: { + control: "color", + table: { category: "Input Styling" }, + }, + inputBorderColor: { + control: "color", + table: { category: "Input Styling" }, + }, + inputBorderRadius: { + control: "text", + table: { category: "Input Styling" }, + }, + inputFontFamily: { + control: "text", + table: { category: "Input Styling" }, + }, + inputFontSize: { + control: "text", + table: { category: "Input Styling" }, + }, + inputFontWeight: { + control: "text", + table: { category: "Input Styling" }, + }, + inputColor: { + control: "color", + table: { category: "Input Styling" }, + }, + inputPaddingX: { + control: "text", + table: { category: "Input Styling" }, + }, + inputPaddingY: { + control: "text", + table: { category: "Input Styling" }, + }, + inputShadow: { + control: "text", + table: { category: "Input Styling" }, + }, + brandColor: { + control: "color", + table: { category: "Survey Styling" }, + }, + }, + decorators: [createCSSVariablesDecorator()], +}; + +export const Default: Story = { + args: { + elementId: "form-field-1", + headline: "Please provide your contact information", + fields: contactInfoFields, + }, +}; + +export const WithDescription: Story = { + args: { + elementId: "form-field-2", + headline: "Please provide your contact information", + description: "We'll use this to contact you about your inquiry", + fields: contactInfoFields, + }, +}; + +export const ContactInfo: Story = { + args: { + elementId: "form-field-contact", + headline: "Contact Information", + description: "Please provide your contact details", + fields: contactInfoFields, + }, +}; + +export const Address: Story = { + args: { + elementId: "form-field-address", + headline: "Shipping Address", + description: "Please provide your shipping address", + fields: addressFields, + }, +}; + +export const Required: Story = { + args: { + elementId: "form-field-3", + headline: "Please provide your contact information", + fields: contactInfoFields, + required: true, + }, +}; + +export const WithValues: Story = { + args: { + elementId: "form-field-4", + headline: "Please provide your contact information", + fields: contactInfoFields, + value: { + firstName: "John", + lastName: "Doe", + email: "john.doe@example.com", + phone: "+1234567890", + company: "Acme Inc.", + }, + }, +}; + +export const WithError: Story = { + args: { + elementId: "form-field-5", + headline: "Please provide your contact information", + fields: contactInfoFields, + required: true, + errorMessage: "Please fill in all required fields", + }, +}; + +export const Disabled: Story = { + args: { + elementId: "form-field-6", + headline: "Please provide your contact information", + fields: contactInfoFields, + disabled: true, + value: { + firstName: "John", + lastName: "Doe", + email: "john.doe@example.com", + }, + }, +}; + +export const PartialFields: Story = { + args: { + elementId: "form-field-7", + headline: "Basic Information", + fields: [ + { id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true }, + { id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true }, + { id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true }, + { + id: "phone", + label: "Phone", + placeholder: "Phone (optional)", + type: "tel", + required: false, + show: false, + }, + ], + }, +}; + +export const OptionalFields: Story = { + args: { + elementId: "form-field-8", + headline: "Optional Information", + fields: [ + { id: "firstName", label: "First Name", placeholder: "First Name", required: false, show: true }, + { id: "lastName", label: "Last Name", placeholder: "Last Name", required: false, show: true }, + { id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true }, + ], + required: false, + }, +}; + +export const RTL: Story = { + args: { + elementId: "form-field-rtl", + headline: "يرجى تقديم معلومات الاتصال الخاصة بك", + description: "سنستخدم هذا للاتصال بك", + dir: "rtl", + fields: [ + { id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true }, + { id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true }, + { + id: "email", + label: "البريد الإلكتروني", + placeholder: "البريد الإلكتروني", + type: "email", + required: true, + show: true, + }, + ], + }, +}; + +export const RTLWithValues: Story = { + args: { + elementId: "form-field-rtl-values", + dir: "rtl", + headline: "يرجى تقديم معلومات الاتصال الخاصة بك", + fields: [ + { id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true }, + { id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true }, + { + id: "email", + label: "البريد الإلكتروني", + placeholder: "البريد الإلكتروني", + type: "email", + required: true, + show: true, + }, + ], + value: { + firstName: "أحمد", + lastName: "محمد", + email: "ahmed@example.com", + }, + }, +}; + +export const MultipleElements: Story = { + render: () => ( +
+ {}} + /> + {}} + /> +
+ ), +}; diff --git a/packages/survey-ui/src/components/elements/form-field.tsx b/packages/survey-ui/src/components/elements/form-field.tsx new file mode 100644 index 0000000000..ad37f47543 --- /dev/null +++ b/packages/survey-ui/src/components/elements/form-field.tsx @@ -0,0 +1,140 @@ +import * as React from "react"; +import { ElementError } from "@/components/general/element-error"; +import { ElementHeader } from "@/components/general/element-header"; +import { Input } from "@/components/general/input"; +import { Label } from "@/components/general/label"; + +/** + * Form field configuration + */ +export interface FormFieldConfig { + /** Unique identifier for the field */ + id: string; + /** Label text for the field */ + label: string; + /** Placeholder text for the input */ + placeholder?: string; + /** Input type (text, email, tel, number, url, etc.) */ + type?: "text" | "email" | "tel" | "number" | "url"; + /** Whether this field is required */ + required?: boolean; + /** Whether this field should be shown */ + show?: boolean; +} + +interface FormFieldProps { + /** Unique identifier for the element container */ + elementId: string; + /** The main element or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Array of form field configurations */ + fields: FormFieldConfig[]; + /** Current values as a record mapping field IDs to their values */ + value?: Record; + /** Callback function called when any field value changes */ + onChange: (value: Record) => void; + /** Whether the entire form is required (shows asterisk indicator) */ + required?: boolean; + /** Error message to display */ + errorMessage?: string; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: "ltr" | "rtl" | "auto"; + /** Whether the controls are disabled */ + disabled?: boolean; +} + +function FormField({ + elementId, + headline, + description, + fields, + value = {}, + onChange, + required = false, + errorMessage, + dir = "auto", + disabled = false, +}: Readonly): React.JSX.Element { + // Ensure value is always an object + const currentValues = React.useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- value can be undefined + return value ?? {}; + }, [value]); + + // Determine if a field is required + const isFieldRequired = (field: FormFieldConfig): boolean => { + if (field.required) { + return true; + } + + // If all fields are optional and the form is required, then fields should be required + const visibleFields = fields.filter((f) => f.show !== false); + const allOptional = visibleFields.every((f) => !f.required); + if (allOptional && required) { + return true; + } + + return false; + }; + + // Handle field value change + const handleFieldChange = (fieldId: string, fieldValue: string): void => { + onChange({ + ...currentValues, + [fieldId]: fieldValue, + }); + }; + + // Get visible fields + const visibleFields = fields.filter((field) => field.show !== false); + + return ( +
+ {/* Headline */} + + + {/* Form Fields */} +
+ + {visibleFields.map((field) => { + const fieldRequired = isFieldRequired(field); + const fieldValue = currentValues[field.id] ?? ""; + const fieldInputId = `${elementId}-${field.id}`; + + // Determine input type + let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text"; + if (field.id === "email" && !field.type) { + inputType = "email"; + } else if (field.id === "phone" && !field.type) { + inputType = "tel"; + } + + return ( +
+ + { + handleFieldChange(field.id, e.target.value); + }} + required={fieldRequired} + disabled={disabled} + dir={dir} + aria-invalid={Boolean(errorMessage) || undefined} + /> +
+ ); + })} +
+
+ ); +} + +export { FormField }; +export type { FormFieldProps }; diff --git a/packages/survey-ui/src/components/elements/matrix.stories.tsx b/packages/survey-ui/src/components/elements/matrix.stories.tsx new file mode 100644 index 0000000000..30925d1be8 --- /dev/null +++ b/packages/survey-ui/src/components/elements/matrix.stories.tsx @@ -0,0 +1,307 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + type BaseStylingOptions, + type InputLayoutStylingOptions, + type LabelStylingOptions, + commonArgTypes, + createCSSVariablesDecorator, + createStatefulRender, + elementStylingArgTypes, + inputStylingArgTypes, + labelStylingArgTypes, + pickArgTypes, + surveyStylingArgTypes, +} from "../../lib/story-helpers"; +import { Matrix, type MatrixOption, type MatrixProps } from "./matrix"; + +type StoryProps = MatrixProps & + Partial & + Record; + +const meta: Meta = { + title: "UI-package/Elements/Matrix", + component: Matrix, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A complete matrix element that combines headline, description, and a table with rows and columns. Each row can have one selected column value. Supports validation and RTL text direction.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + ...commonArgTypes, + rows: { + control: "object", + description: "Array of row options (left side)", + table: { category: "Content" }, + }, + columns: { + control: "object", + description: "Array of column options (top header)", + table: { category: "Content" }, + }, + value: { + control: "object", + description: "Record mapping row ID to column ID", + table: { category: "State" }, + }, + }, + render: createStatefulRender(Matrix), +}; + +export default meta; +type Story = StoryObj; + +// Decorator to apply CSS variables from story args + +const defaultRows: MatrixOption[] = [ + { id: "row-1", label: "Row 1" }, + { id: "row-2", label: "Row 2" }, + { id: "row-3", label: "Row 3" }, +]; + +const defaultColumns: MatrixOption[] = [ + { id: "col-1", label: "Column 1" }, + { id: "col-2", label: "Column 2" }, + { id: "col-3", label: "Column 3" }, + { id: "col-4", label: "Column 4" }, +]; + +export const StylingPlayground: Story = { + args: { + headline: "Rate each item", + description: "Select a value for each row", + rows: defaultRows, + columns: defaultColumns, + }, + argTypes: { + ...elementStylingArgTypes, + ...labelStylingArgTypes, + ...pickArgTypes(inputStylingArgTypes, ["inputBgColor", "inputBorderColor"]), + ...surveyStylingArgTypes, + }, + decorators: [createCSSVariablesDecorator()], +}; + +export const Default: Story = { + args: { + elementId: "matrix-default", + inputId: "matrix-default-input", + headline: "Rate each item", + rows: defaultRows, + columns: defaultColumns, + }, +}; + +export const WithDescription: Story = { + args: { + elementId: "matrix-with-description", + inputId: "matrix-with-description-input", + headline: "How satisfied are you with each feature?", + description: "Please rate each feature on a scale from 1 to 5", + rows: [ + { id: "feature-1", label: "Feature 1" }, + { id: "feature-2", label: "Feature 2" }, + { id: "feature-3", label: "Feature 3" }, + ], + columns: [ + { id: "1", label: "1" }, + { id: "2", label: "2" }, + { id: "3", label: "3" }, + { id: "4", label: "4" }, + { id: "5", label: "5" }, + ], + }, +}; + +export const Required: Story = { + args: { + elementId: "matrix-required", + inputId: "matrix-required-input", + headline: "Rate each item", + description: "Please select a value for each row", + rows: defaultRows, + columns: defaultColumns, + required: true, + }, +}; + +export const WithSelections: Story = { + args: { + elementId: "matrix-selections", + inputId: "matrix-selections-input", + headline: "Rate each item", + description: "Select a value for each row", + rows: defaultRows, + columns: defaultColumns, + value: { + "row-1": "col-2", + "row-2": "col-3", + }, + }, +}; + +export const WithError: Story = { + args: { + elementId: "matrix-error", + inputId: "matrix-error-input", + headline: "Rate each item", + description: "Please select a value for each row", + rows: defaultRows, + columns: defaultColumns, + errorMessage: "Please complete all rows", + required: true, + }, +}; + +export const Disabled: Story = { + args: { + elementId: "matrix-disabled", + inputId: "matrix-disabled-input", + headline: "This element is disabled", + description: "You cannot change the selection", + rows: defaultRows, + columns: defaultColumns, + value: { + "row-1": "col-2", + "row-2": "col-3", + }, + disabled: true, + }, +}; + +export const RatingScale: Story = { + args: { + elementId: "matrix-rating-scale", + inputId: "matrix-rating-scale-input", + headline: "Rate your experience", + description: "How would you rate each aspect?", + rows: [ + { id: "quality", label: "Quality" }, + { id: "service", label: "Service" }, + { id: "value", label: "Value for Money" }, + { id: "support", label: "Customer Support" }, + ], + columns: [ + { id: "poor", label: "Poor" }, + { id: "fair", label: "Fair" }, + { id: "good", label: "Good" }, + { id: "very-good", label: "Very Good" }, + { id: "excellent", label: "Excellent" }, + ], + }, +}; + +export const NumericScale: Story = { + args: { + elementId: "matrix-numeric-scale", + inputId: "matrix-numeric-scale-input", + headline: "Rate from 0 to 10", + description: "Select a number for each item", + rows: [ + { id: "item-1", label: "Item 1" }, + { id: "item-2", label: "Item 2" }, + { id: "item-3", label: "Item 3" }, + ], + columns: [ + { id: "0", label: "0" }, + { id: "1", label: "1" }, + { id: "2", label: "2" }, + { id: "3", label: "3" }, + { id: "4", label: "4" }, + { id: "5", label: "5" }, + { id: "6", label: "6" }, + { id: "7", label: "7" }, + { id: "8", label: "8" }, + { id: "9", label: "9" }, + { id: "10", label: "10" }, + ], + }, +}; + +export const RTL: Story = { + args: { + elementId: "matrix-rtl", + inputId: "matrix-rtl-input", + headline: "قيم كل عنصر", + description: "اختر قيمة لكل صف", + dir: "rtl", + rows: [ + { id: "row-1", label: "الصف الأول" }, + { id: "row-2", label: "الصف الثاني" }, + { id: "row-3", label: "الصف الثالث" }, + ], + columns: [ + { id: "col-1", label: "عمود 1" }, + { id: "col-2", label: "عمود 2" }, + { id: "col-3", label: "عمود 3" }, + { id: "col-4", label: "عمود 4" }, + ], + }, +}; + +export const RTLWithSelections: Story = { + args: { + elementId: "matrix-rtl-selections", + inputId: "matrix-rtl-selections-input", + dir: "rtl", + headline: "قيم كل عنصر", + description: "يرجى اختيار قيمة لكل صف", + rows: [ + { id: "quality", label: "الجودة" }, + { id: "service", label: "الخدمة" }, + { id: "value", label: "القيمة" }, + ], + columns: [ + { id: "poor", label: "ضعيف" }, + { id: "fair", label: "مقبول" }, + { id: "good", label: "جيد" }, + { id: "very-good", label: "جيد جداً" }, + { id: "excellent", label: "ممتاز" }, + ], + value: { + quality: "good", + service: "very-good", + }, + }, +}; + +export const MultipleElements: Story = { + render: () => ( +
+ {}} + /> + {}} + /> +
+ ), +}; diff --git a/packages/survey-ui/src/components/elements/matrix.tsx b/packages/survey-ui/src/components/elements/matrix.tsx new file mode 100644 index 0000000000..2eb050045d --- /dev/null +++ b/packages/survey-ui/src/components/elements/matrix.tsx @@ -0,0 +1,166 @@ +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import * as React from "react"; +import { ElementError } from "@/components/general/element-error"; +import { ElementHeader } from "@/components/general/element-header"; +import { Label } from "@/components/general/label"; +import { RadioGroupItem } from "@/components/general/radio-group"; +import { cn } from "@/lib/utils"; + +/** + * Option for matrix element rows and columns + */ +export interface MatrixOption { + /** Unique identifier for the option */ + id: string; + /** Display label for the option */ + label: string; +} + +interface MatrixProps { + /** Unique identifier for the element container */ + elementId: string; + /** The main element or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Unique identifier for the matrix group */ + inputId: string; + /** Array of row options (left side) */ + rows: MatrixOption[]; + /** Array of column options (top header) */ + columns: MatrixOption[]; + /** Currently selected values: Record mapping row ID to column ID */ + value?: Record; + /** Callback function called when selection changes */ + onChange: (value: Record) => void; + /** Whether the field is required (shows asterisk indicator) */ + required?: boolean; + /** Error message to display */ + errorMessage?: string; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: "ltr" | "rtl" | "auto"; + /** Whether the options are disabled */ + disabled?: boolean; +} + +function Matrix({ + elementId, + headline, + description, + inputId, + rows, + columns, + value = {}, + onChange, + required = false, + errorMessage, + dir = "auto", + disabled = false, +}: Readonly): React.JSX.Element { + // Ensure value is always an object (value already has default of {}) + const selectedValues = value; + + // Check which rows have errors (no selection when required) + const hasError = Boolean(errorMessage); + const rowsWithErrors = hasError && required ? rows.filter((row) => !selectedValues[row.id]) : []; + + const handleRowChange = (rowId: string, columnId: string): void => { + // Toggle: if same column is selected, deselect it + if (selectedValues[rowId] === columnId) { + // Create new object without the rowId property + const { [rowId]: _, ...rest } = selectedValues; + onChange(rest); + } else { + onChange({ ...selectedValues, [rowId]: columnId }); + } + }; + + return ( +
+ {/* Headline */} + + + {/* Matrix Table */} +
+ + + {/* Table container with overflow for mobile */} +
+ + {/* Column headers */} + + + + ))} + + + {/* Rows */} + + {rows.map((row, index) => { + const rowGroupId = `${inputId}-row-${row.id}`; + const selectedColumnId = selectedValues[row.id]; + const rowHasError = rowsWithErrors.includes(row); + const baseBgColor = index % 2 === 0 ? "bg-input-bg" : "bg-transparent"; + + return ( + { + handleRowChange(row.id, newColumnId); + }} + disabled={disabled} + required={required} + aria-invalid={Boolean(errorMessage)}> + + {/* Row label */} + + {/* Column options for this row */} + {columns.map((column, colIndex) => { + const cellId = `${rowGroupId}-${column.id}`; + const isLastColumn = colIndex === columns.length - 1; + + return ( + + ); + })} + + + ); + })} + +
+ {columns.map((column) => ( + + +
+
+ + {rowHasError ? ( + Select one option + ) : null} +
+
+ +
+
+
+
+ ); +} + +export { Matrix }; +export type { MatrixProps }; diff --git a/packages/survey-ui/src/components/elements/multi-select.stories.tsx b/packages/survey-ui/src/components/elements/multi-select.stories.tsx new file mode 100644 index 0000000000..c3c05bc8ab --- /dev/null +++ b/packages/survey-ui/src/components/elements/multi-select.stories.tsx @@ -0,0 +1,353 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React, { useEffect, useState } from "react"; +import { + type BaseStylingOptions, + type CheckboxInputStylingOptions, + type LabelStylingOptions, + type OptionStylingOptions, + commonArgTypes, + createCSSVariablesDecorator, + elementStylingArgTypes, + labelStylingArgTypes, + optionStylingArgTypes, + surveyStylingArgTypes, +} from "../../lib/story-helpers"; +import { MultiSelect, type MultiSelectOption, type MultiSelectProps } from "./multi-select"; + +type StoryProps = MultiSelectProps & + Partial & + Record; + +const meta: Meta = { + title: "UI-package/Elements/MultiSelect", + component: MultiSelect, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A complete multi-select element that combines headline, description, and checkbox options. Supports multiple selections, validation, and RTL text direction.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + ...commonArgTypes, + options: { + control: "object", + description: "Array of options to choose from", + table: { category: "Content" }, + }, + value: { + control: "object", + description: "Array of selected option IDs", + table: { category: "State" }, + }, + variant: { + control: { type: "select" }, + options: ["list", "dropdown"], + description: "Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu", + table: { category: "Layout" }, + }, + placeholder: { + control: "text", + description: "Placeholder text for dropdown button when no options are selected", + table: { category: "Content" }, + }, + }, + render: function Render(args: StoryProps) { + const [value, setValue] = useState(args.value); + const [otherValue, setOtherValue] = useState(args.otherValue); + const handleOtherValueChange = (v: string) => { + setOtherValue(v); + args.onOtherValueChange?.(v); + }; + + useEffect(() => { + setValue(args.value); + }, [args.value]); + + return ( + { + setValue(v); + args.onChange?.(v); + }} + otherValue={otherValue} + onOtherValueChange={handleOtherValueChange} + /> + ); + }, +}; + +export default meta; +type Story = StoryObj; + +const defaultOptions: MultiSelectOption[] = [ + { id: "option-1", label: "Option 1" }, + { id: "option-2", label: "Option 2" }, + { id: "option-3", label: "Option 3" }, + { id: "option-4", label: "Option 4" }, +]; + +export const StylingPlayground: Story = { + args: { + headline: "Which features do you use?", + description: "Select all that apply", + options: defaultOptions, + }, + argTypes: { + ...elementStylingArgTypes, + ...labelStylingArgTypes, + ...optionStylingArgTypes, + ...surveyStylingArgTypes, + }, + decorators: [createCSSVariablesDecorator()], +}; + +export const Default: Story = { + args: { + headline: "Which features do you use?", + options: defaultOptions, + }, +}; + +export const WithDescription: Story = { + args: { + headline: "What programming languages do you know?", + description: "Select all programming languages you're familiar with", + options: [ + { id: "js", label: "JavaScript" }, + { id: "ts", label: "TypeScript" }, + { id: "python", label: "Python" }, + { id: "java", label: "Java" }, + { id: "go", label: "Go" }, + { id: "rust", label: "Rust" }, + ], + }, +}; + +export const Required: Story = { + args: { + headline: "Select your interests", + description: "Please select at least one option", + options: [ + { id: "tech", label: "Technology" }, + { id: "design", label: "Design" }, + { id: "marketing", label: "Marketing" }, + { id: "sales", label: "Sales" }, + ], + required: true, + }, +}; + +export const WithSelections: Story = { + args: { + headline: "Which features do you use?", + description: "Select all that apply", + options: defaultOptions, + value: ["option-1", "option-3"], + }, +}; + +export const WithError: Story = { + args: { + headline: "Select your preferences", + description: "Please select at least one option", + options: [ + { id: "email", label: "Email notifications" }, + { id: "sms", label: "SMS notifications" }, + { id: "push", label: "Push notifications" }, + ], + errorMessage: "Please select at least one option", + required: true, + }, +}; + +export const Disabled: Story = { + args: { + headline: "This element is disabled", + description: "You cannot change the selection", + options: defaultOptions, + value: ["option-2"], + disabled: true, + }, +}; + +export const ManyOptions: Story = { + args: { + headline: "Select all that apply", + description: "Choose as many as you like", + options: [ + { id: "1", label: "Option 1" }, + { id: "2", label: "Option 2" }, + { id: "3", label: "Option 3" }, + { id: "4", label: "Option 4" }, + { id: "5", label: "Option 5" }, + { id: "6", label: "Option 6" }, + { id: "7", label: "Option 7" }, + { id: "8", label: "Option 8" }, + { id: "9", label: "Option 9" }, + { id: "10", label: "Option 10" }, + ], + }, +}; + +export const RTL: Story = { + args: { + headline: "ما هي الميزات التي تستخدمها؟", + dir: "rtl", + description: "اختر كل ما ينطبق", + options: [ + { id: "opt-1", label: "الخيار الأول" }, + { id: "opt-2", label: "الخيار الثاني" }, + { id: "opt-3", label: "الخيار الثالث" }, + { id: "opt-4", label: "الخيار الرابع" }, + ], + }, +}; + +export const RTLWithSelections: Story = { + args: { + headline: "ما هي اهتماماتك؟", + dir: "rtl", + description: "يرجى اختيار جميع الخيارات المناسبة", + options: [ + { id: "tech", label: "التكنولوجيا" }, + { id: "design", label: "التصميم" }, + { id: "marketing", label: "التسويق" }, + { id: "sales", label: "المبيعات" }, + ], + value: ["tech", "design"], + }, +}; + +export const MultipleElements: Story = { + render: () => ( +
+ {}} + /> + {}} + /> +
+ ), +}; + +export const Dropdown: Story = { + args: { + headline: "Which features do you use?", + description: "Select all that apply", + options: defaultOptions, + variant: "dropdown", + placeholder: "Select options...", + }, +}; + +export const DropdownWithSelections: Story = { + args: { + headline: "Which features do you use?", + description: "Select all that apply", + options: defaultOptions, + value: ["option-1", "option-3"], + variant: "dropdown", + placeholder: "Select options...", + }, +}; + +export const WithOtherOption: Story = { + render: () => { + const [value, setValue] = React.useState([]); + const [otherValue, setOtherValue] = React.useState(""); + + return ( +
+ +
+ ); + }, +}; + +export const WithOtherOptionSelected: Story = { + render: () => { + const [value, setValue] = React.useState(["option-1", "other"]); + const [otherValue, setOtherValue] = React.useState("Custom feature"); + + return ( +
+ +
+ ); + }, +}; + +export const DropdownWithOtherOption: Story = { + render: () => { + const [value, setValue] = React.useState([]); + const [otherValue, setOtherValue] = React.useState(""); + + return ( +
+ +
+ ); + }, +}; diff --git a/packages/survey-ui/src/components/elements/multi-select.tsx b/packages/survey-ui/src/components/elements/multi-select.tsx new file mode 100644 index 0000000000..129860fd32 --- /dev/null +++ b/packages/survey-ui/src/components/elements/multi-select.tsx @@ -0,0 +1,549 @@ +import { ChevronDown } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/general/button"; +import { Checkbox } from "@/components/general/checkbox"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/general/dropdown-menu"; +import { ElementError } from "@/components/general/element-error"; +import { ElementHeader } from "@/components/general/element-header"; +import { Input } from "@/components/general/input"; +import { cn } from "@/lib/utils"; + +/** + * Option for multi-select element + */ +export interface MultiSelectOption { + /** Unique identifier for the option */ + id: string; + /** Display label for the option */ + label: string; +} + +/** + * Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) + */ +type TextDirection = "ltr" | "rtl" | "auto"; + +interface MultiSelectProps { + /** Unique identifier for the element container */ + elementId: string; + /** The main element or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Unique identifier for the multi-select group */ + inputId: string; + /** Array of options to choose from */ + options: MultiSelectOption[]; + /** Currently selected option IDs */ + value?: string[]; + /** Callback function called when selection changes */ + onChange: (value: string[]) => void; + /** Whether the field is required (shows asterisk indicator) */ + required?: boolean; + /** Error message to display below the options */ + errorMessage?: string; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: TextDirection; + /** Whether the options are disabled */ + disabled?: boolean; + /** Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu */ + variant?: "list" | "dropdown"; + /** Placeholder text for dropdown button when no options are selected */ + placeholder?: string; + /** ID for the 'other' option that allows custom input */ + otherOptionId?: string; + /** Label for the 'other' option */ + otherOptionLabel?: string; + /** Placeholder text for the 'other' input field */ + otherOptionPlaceholder?: string; + /** Custom value entered in the 'other' input field */ + otherValue?: string; + /** Callback when the 'other' input value changes */ + onOtherValueChange?: (value: string) => void; + /** IDs of options that should be exclusive (selecting them deselects all others) */ + exclusiveOptionIds?: string[]; +} + +// Shared className for option labels +const optionLabelClassName = "font-option text-option font-option-weight text-option-label"; + +// Shared className for option containers +const getOptionContainerClassName = (isSelected: boolean, isDisabled: boolean): string => + cn( + "relative flex flex-col border transition-colors outline-none", + "rounded-option px-option-x py-option-y", + isSelected ? "bg-option-selected-bg border-brand" : "bg-option-bg border-option-border", + "focus-within:border-brand focus-within:bg-option-selected-bg", + "hover:bg-option-hover-bg", + isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer" + ); + +interface DropdownVariantProps { + inputId: string; + options: MultiSelectOption[]; + selectedValues: string[]; + handleOptionAdd: (optionId: string) => void; + handleOptionRemove: (optionId: string) => void; + disabled: boolean; + headline: string; + errorMessage?: string; + displayText: string; + hasOtherOption: boolean; + otherOptionId?: string; + isOtherSelected: boolean; + otherOptionLabel: string; + otherValue: string; + handleOtherInputChange: (e: React.ChangeEvent) => void; + otherOptionPlaceholder: string; + dir: TextDirection; + otherInputRef: React.RefObject; + required: boolean; +} + +function DropdownVariant({ + inputId, + options, + selectedValues, + handleOptionAdd, + handleOptionRemove, + disabled, + headline, + errorMessage, + displayText, + hasOtherOption, + otherOptionId, + isOtherSelected, + otherOptionLabel, + otherValue, + handleOtherInputChange, + otherOptionPlaceholder, + dir, + otherInputRef, + required, +}: Readonly): React.JSX.Element { + const getIsRequired = (): boolean => { + const responseValues = [...selectedValues]; + if (isOtherSelected && otherValue) { + responseValues.push(otherValue); + } + const hasResponse = Array.isArray(responseValues) && responseValues.length > 0; + return required && hasResponse ? false : required; + }; + + const isRequired = getIsRequired(); + + const handleOptionToggle = (optionId: string) => { + if (selectedValues.includes(optionId)) { + handleOptionRemove(optionId); + } else { + handleOptionAdd(optionId); + } + }; + + return ( + <> + + + + + + + {options + .filter((option) => option.id !== "none") + .map((option) => { + const isChecked = selectedValues.includes(option.id); + const optionId = `${inputId}-${option.id}`; + + return ( + { + handleOptionToggle(option.id); + }} + disabled={disabled}> + {option.label} + + ); + })} + {hasOtherOption && otherOptionId ? ( + { + if (isOtherSelected) { + handleOptionRemove(otherOptionId); + } else { + handleOptionAdd(otherOptionId); + } + }} + disabled={disabled}> + {otherOptionLabel} + + ) : null} + {options + .filter((option) => option.id === "none") + .map((option) => { + const isChecked = selectedValues.includes(option.id); + const optionId = `${inputId}-${option.id}`; + + return ( + { + handleOptionToggle(option.id); + }} + disabled={disabled}> + {option.label} + + ); + })} + + + {isOtherSelected ? ( + + ) : null} + + ); +} + +interface ListVariantProps { + inputId: string; + options: MultiSelectOption[]; + selectedValues: string[]; + value: string[]; + handleOptionAdd: (optionId: string) => void; + handleOptionRemove: (optionId: string) => void; + disabled: boolean; + headline: string; + errorMessage?: string; + hasOtherOption: boolean; + otherOptionId?: string; + isOtherSelected: boolean; + otherOptionLabel: string; + otherValue: string; + handleOtherInputChange: (e: React.ChangeEvent) => void; + otherOptionPlaceholder: string; + dir: TextDirection; + otherInputRef: React.RefObject; + required: boolean; +} + +function ListVariant({ + inputId, + options, + selectedValues, + value, + handleOptionAdd, + handleOptionRemove, + disabled, + headline, + errorMessage, + hasOtherOption, + otherOptionId, + isOtherSelected, + otherOptionLabel, + otherValue, + handleOtherInputChange, + otherOptionPlaceholder, + dir, + otherInputRef, + required, +}: Readonly): React.JSX.Element { + const isNoneSelected = value.includes("none"); + + const getIsRequired = (): boolean => { + const responseValues = [...value]; + if (isOtherSelected && otherValue) { + responseValues.push(otherValue); + } + const hasResponse = Array.isArray(responseValues) && responseValues.length > 0; + return required && hasResponse ? false : required; + }; + + const isRequired = getIsRequired(); + + return ( + <> + +
+ {options + .filter((option) => option.id !== "none") + .map((option, index) => { + const isChecked = selectedValues.includes(option.id); + const optionId = `${inputId}-${option.id}`; + const isDisabled = disabled || (isNoneSelected && option.id !== "none"); + // Only mark the first checkbox as required for HTML5 validation + // This ensures at least one selection is required, not all + const isFirstOption = index === 0; + return ( + + ); + })} + {hasOtherOption && otherOptionId ? ( +
+ +
+ ) : null} + {options + .filter((option) => option.id === "none") + .map((option) => { + const isChecked = selectedValues.includes(option.id); + const optionId = `${inputId}-${option.id}`; + const isDisabled = disabled || (isNoneSelected && option.id !== "none"); + return ( + + ); + })} +
+ + ); +} + +function MultiSelect({ + elementId, + headline, + description, + inputId, + options, + value = [], + onChange, + required = false, + errorMessage, + dir = "auto", + disabled = false, + variant = "list", + placeholder = "Select options...", + otherOptionId, + otherOptionLabel = "Other", + otherOptionPlaceholder = "Please specify", + otherValue = "", + onOtherValueChange, + exclusiveOptionIds = [], +}: Readonly): React.JSX.Element { + // Ensure value is always an array + const selectedValues = Array.isArray(value) ? value : []; + const hasOtherOption = Boolean(otherOptionId); + const isOtherSelected = Boolean(hasOtherOption && otherOptionId && selectedValues.includes(otherOptionId)); + const otherInputRef = React.useRef(null); + + React.useEffect(() => { + if (!isOtherSelected || disabled) return; + + // Delay focus to win against Radix focus restoration when dropdown closes / checkbox receives focus. + const timeoutId = globalThis.setTimeout(() => { + globalThis.requestAnimationFrame(() => { + otherInputRef.current?.focus(); + }); + }, 0); + + return () => { + globalThis.clearTimeout(timeoutId); + }; + }, [isOtherSelected, disabled, variant]); + + const handleOptionAdd = (optionId: string): void => { + if (exclusiveOptionIds.includes(optionId)) { + onChange([optionId]); + } else { + const newValues = selectedValues.filter((id) => !exclusiveOptionIds.includes(id)); + onChange([...newValues, optionId]); + } + }; + + const handleOptionRemove = (optionId: string): void => { + onChange(selectedValues.filter((id) => id !== optionId)); + }; + + const handleOtherInputChange = (e: React.ChangeEvent): void => { + onOtherValueChange?.(e.target.value); + }; + + // Get selected option labels for dropdown display + const selectedLabels = options.filter((opt) => selectedValues.includes(opt.id)).map((opt) => opt.label); + + let displayText = placeholder; + if (selectedLabels.length > 0) { + displayText = + selectedLabels.length === 1 ? selectedLabels[0] : `${String(selectedLabels.length)} selected`; + } + + return ( +
+ {/* Headline */} + + + {/* Options */} +
+ {variant === "dropdown" ? ( + + ) : ( + + )} +
+
+ ); +} + +export { MultiSelect }; +export type { MultiSelectProps }; diff --git a/packages/survey-ui/src/components/elements/nps.stories.tsx b/packages/survey-ui/src/components/elements/nps.stories.tsx new file mode 100644 index 0000000000..98fc5eb014 --- /dev/null +++ b/packages/survey-ui/src/components/elements/nps.stories.tsx @@ -0,0 +1,244 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { + type BaseStylingOptions, + type LabelStylingOptions, + commonArgTypes, + createCSSVariablesDecorator, + createStatefulRender, + elementStylingArgTypes, + inputStylingArgTypes, + labelStylingArgTypes, + pickArgTypes, + surveyStylingArgTypes, +} from "../../lib/story-helpers"; +import { NPS, type NPSProps } from "./nps"; + +type StoryProps = NPSProps & Partial & Record; + +const meta: Meta = { + title: "UI-package/Elements/NPS", + component: NPS, + parameters: { + layout: "centered", + docs: { + description: { + component: + "A Net Promoter Score (NPS) element. Users can select a rating from 0 to 10 to indicate how likely they are to recommend something.", + }, + }, + }, + tags: ["autodocs"], + argTypes: { + ...commonArgTypes, + value: { + control: { type: "number", min: 0, max: 10 }, + description: "Currently selected NPS value (0-10)", + table: { category: "State" }, + }, + lowerLabel: { + control: "text", + description: "Label for the lower end of the scale", + table: { category: "Content" }, + }, + upperLabel: { + control: "text", + description: "Label for the upper end of the scale", + table: { category: "Content" }, + }, + colorCoding: { + control: "boolean", + description: "Whether color coding is enabled", + table: { category: "Content" }, + }, + }, + render: createStatefulRender(NPS), +}; + +export default meta; +type Story = StoryObj; + +export const StylingPlayground: Story = { + args: { + elementId: "nps-1", + inputId: "nps-input-1", + headline: "How likely are you to recommend us to a friend or colleague?", + description: "Please rate from 0 to 10", + lowerLabel: "Not at all likely", + upperLabel: "Extremely likely", + }, + argTypes: { + ...elementStylingArgTypes, + ...labelStylingArgTypes, + ...pickArgTypes(inputStylingArgTypes, [ + "inputBgColor", + "inputBorderColor", + "inputColor", + "inputFontWeight", + "inputBorderRadius", + ]), + ...surveyStylingArgTypes, + }, + decorators: [createCSSVariablesDecorator()], +}; + +export const Default: Story = { + args: { + elementId: "nps-1", + inputId: "nps-input-1", + headline: "How likely are you to recommend us to a friend or colleague?", + }, +}; + +export const WithDescription: Story = { + args: { + elementId: "nps-2", + inputId: "nps-input-2", + headline: "How likely are you to recommend us to a friend or colleague?", + description: "Please rate from 0 to 10, where 0 is not at all likely and 10 is extremely likely", + }, +}; + +export const WithLabels: Story = { + args: { + elementId: "nps-labels", + inputId: "nps-input-labels", + headline: "How likely are you to recommend us to a friend or colleague?", + lowerLabel: "Not at all likely", + upperLabel: "Extremely likely", + }, +}; + +export const WithSelection: Story = { + args: { + elementId: "nps-selection", + inputId: "nps-input-selection", + headline: "How likely are you to recommend us to a friend or colleague?", + value: 9, + }, +}; + +export const Required: Story = { + args: { + elementId: "nps-required", + inputId: "nps-input-required", + headline: "How likely are you to recommend us to a friend or colleague?", + required: true, + }, +}; + +export const WithError: Story = { + args: { + elementId: "nps-error", + inputId: "nps-input-error", + headline: "How likely are you to recommend us to a friend or colleague?", + required: true, + errorMessage: "Please select a rating", + }, +}; + +export const Disabled: Story = { + args: { + elementId: "nps-disabled", + inputId: "nps-input-disabled", + headline: "How likely are you to recommend us to a friend or colleague?", + value: 8, + disabled: true, + }, +}; + +export const ColorCoding: Story = { + args: { + elementId: "nps-color", + inputId: "nps-input-color", + headline: "How likely are you to recommend us to a friend or colleague?", + colorCoding: true, + lowerLabel: "Not at all likely", + upperLabel: "Extremely likely", + }, +}; + +export const Promoter: Story = { + args: { + elementId: "nps-promoter", + inputId: "nps-input-promoter", + headline: "How likely are you to recommend us to a friend or colleague?", + value: 9, + colorCoding: true, + lowerLabel: "Not at all likely", + upperLabel: "Extremely likely", + }, +}; + +export const Passive: Story = { + args: { + elementId: "nps-passive", + inputId: "nps-input-passive", + headline: "How likely are you to recommend us to a friend or colleague?", + value: 7, + colorCoding: true, + lowerLabel: "Not at all likely", + upperLabel: "Extremely likely", + }, +}; + +export const Detractor: Story = { + args: { + elementId: "nps-detractor", + inputId: "nps-input-detractor", + headline: "How likely are you to recommend us to a friend or colleague?", + value: 5, + colorCoding: true, + lowerLabel: "Not at all likely", + upperLabel: "Extremely likely", + }, +}; + +export const RTL: Story = { + args: { + elementId: "nps-rtl", + dir: "rtl", + inputId: "nps-input-rtl", + headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟", + description: "يرجى التقييم من 0 إلى 10", + lowerLabel: "غير محتمل على الإطلاق", + upperLabel: "محتمل للغاية", + }, +}; + +export const RTLWithSelection: Story = { + args: { + elementId: "nps-rtl-selection", + dir: "rtl", + inputId: "nps-input-rtl-selection", + headline: "ما مدى احتمالية أن توصي بنا لصديق أو زميل؟", + value: 8, + lowerLabel: "غير محتمل على الإطلاق", + upperLabel: "محتمل للغاية", + }, +}; + +export const MultipleElements: Story = { + render: () => ( +
+ {}} + /> + {}} + /> +
+ ), +}; diff --git a/packages/survey-ui/src/components/elements/nps.tsx b/packages/survey-ui/src/components/elements/nps.tsx new file mode 100644 index 0000000000..fe3a7cfb9c --- /dev/null +++ b/packages/survey-ui/src/components/elements/nps.tsx @@ -0,0 +1,196 @@ +import * as React from "react"; +import { ElementError } from "@/components/general/element-error"; +import { ElementHeader } from "@/components/general/element-header"; +import { Label } from "@/components/general/label"; +import { cn } from "@/lib/utils"; + +interface NPSProps { + /** Unique identifier for the element container */ + elementId: string; + /** The main element or prompt text displayed as the headline */ + headline: string; + /** Optional descriptive text displayed below the headline */ + description?: string; + /** Unique identifier for the NPS group */ + inputId: string; + /** Currently selected NPS value (0 to 10) */ + value?: number; + /** Callback function called when NPS value changes */ + onChange: (value: number) => void; + /** Optional label for the lower end of the scale */ + lowerLabel?: string; + /** Optional label for the upper end of the scale */ + upperLabel?: string; + /** Whether color coding is enabled */ + colorCoding?: boolean; + /** Whether the field is required (shows asterisk indicator) */ + required?: boolean; + /** Error message to display */ + errorMessage?: string; + /** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */ + dir?: "ltr" | "rtl" | "auto"; + /** Whether the controls are disabled */ + disabled?: boolean; +} + +function NPS({ + elementId, + headline, + description, + inputId, + value, + onChange, + lowerLabel, + upperLabel, + colorCoding = false, + required = false, + errorMessage, + dir = "auto", + disabled = false, +}: Readonly): React.JSX.Element { + const [hoveredValue, setHoveredValue] = React.useState(null); + + // Ensure value is within valid range (0-10) + const currentValue = value !== undefined && value >= 0 && value <= 10 ? value : undefined; + + // Handle NPS selection + const handleSelect = (npsValue: number): void => { + if (!disabled) { + onChange(npsValue); + } + }; + + // Handle keyboard navigation + const handleKeyDown = (npsValue: number) => (e: React.KeyboardEvent) => { + if (disabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelect(npsValue); + } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + const direction = e.key === "ArrowLeft" ? -1 : 1; + const newValue = Math.max(0, Math.min(10, (currentValue ?? 0) + direction)); + handleSelect(newValue); + } + }; + + // Get NPS option color for color coding + const getNPSOptionColor = (idx: number): string => { + if (idx > 8) return "bg-emerald-100"; // 9-10: Promoters (green) + if (idx > 6) return "bg-orange-100"; // 7-8: Passives (orange) + return "bg-rose-100"; // 0-6: Detractors (red) + }; + + // Render NPS option (0-10) + const renderNPSOption = (number: number): React.JSX.Element => { + const isSelected = currentValue === number; + const isHovered = hoveredValue === number; + const isLast = number === 10; // Last option is 10 + const isFirst = number === 0; // First option is 0 + + // Determine border radius and border classes + // Use right border for all items to create separators, left border only on first item + let borderRadiusClasses = ""; + let borderClasses = "border-t border-b border-r"; + + if (isFirst) { + borderRadiusClasses = dir === "rtl" ? "rounded-r-input" : "rounded-l-input"; + borderClasses = "border-t border-b border-l border-r"; + } else if (isLast) { + borderRadiusClasses = dir === "rtl" ? "rounded-l-input" : "rounded-r-input"; + // Last item keeps right border for rounded corner + } + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- label is interactive +