From 46d1823cccaaa9a45be33c69ee22f8993a01ea18 Mon Sep 17 00:00:00 2001
From: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Date: Fri, 4 Apr 2025 11:27:04 +0100
Subject: [PATCH] feat: add new UI component for multi option inputs (#20533)
* add multi option input support
* fix import
* add multi-key-field-support
---
apps/ui-playground/app/global.css | 2 +-
apps/ui-playground/app/layout.tsx | 20 +-
.../design/components/MultiInputField.mdx | 106 ++++++
.../multiInputField.customization.tsx | 71 ++++
.../components/multiInputField.paste.tsx | 76 +++++
.../components/multiInputField.states.tsx | 55 +++
packages/ui/components/form/index.ts | 2 +
.../form/inputs/MultiOptionInput.tsx | 312 ++++++++++++++++++
packages/ui/package.json | 4 +-
yarn.lock | 11 +-
10 files changed, 647 insertions(+), 12 deletions(-)
create mode 100644 apps/ui-playground/content/design/components/MultiInputField.mdx
create mode 100644 apps/ui-playground/content/design/components/multiInputField.customization.tsx
create mode 100644 apps/ui-playground/content/design/components/multiInputField.paste.tsx
create mode 100644 apps/ui-playground/content/design/components/multiInputField.states.tsx
create mode 100644 packages/ui/components/form/inputs/MultiOptionInput.tsx
diff --git a/apps/ui-playground/app/global.css b/apps/ui-playground/app/global.css
index c995348e16..cf8a5c6ec6 100644
--- a/apps/ui-playground/app/global.css
+++ b/apps/ui-playground/app/global.css
@@ -1027,4 +1027,4 @@ select:focus {
.prose :where(a:not([data-card])):not(:where([class~="not-prose"] *)) {
text-decoration: none;
-}
\ No newline at end of file
+}
diff --git a/apps/ui-playground/app/layout.tsx b/apps/ui-playground/app/layout.tsx
index 9c45cca71e..e7dbf4ab50 100644
--- a/apps/ui-playground/app/layout.tsx
+++ b/apps/ui-playground/app/layout.tsx
@@ -1,16 +1,11 @@
import { RootProvider } from "fumadocs-ui/provider";
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Inter } from "next/font/google";
import "./global.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
+const inter = Inter({
+ variable: "--font-inter",
subsets: ["latin"],
});
@@ -26,7 +21,14 @@ export default function RootLayout({
}>) {
return (
-
+
+
+
+
{children}
diff --git a/apps/ui-playground/content/design/components/MultiInputField.mdx b/apps/ui-playground/content/design/components/MultiInputField.mdx
new file mode 100644
index 0000000000..e97a237c5e
--- /dev/null
+++ b/apps/ui-playground/content/design/components/MultiInputField.mdx
@@ -0,0 +1,106 @@
+---
+title: MultiOptionInput
+---
+import { MultiOptionInput } from "@calcom/ui/components/form"
+import { RenderComponentWithSnippet } from "@/app/components/render"
+import { Row } from "@/app/components/row"
+import { StatesExample } from "./multiInputField.states"
+import { PasteExample } from "./multiInputField.paste"
+import { CustomizationExample } from "./multiInputField.customization"
+
+## Overview
+
+The MultiOptionInput component is a flexible form input that allows users to manage a list of options with features like:
+- Adding and removing options
+- Reordering options with move up/down buttons
+- Pasting multiple options with configurable delimiters
+- Minimum options requirement
+- Customizable placeholders and labels
+- Key-value pair mode for environment variables, configuration settings, etc.
+
+## States
+
+MultiOptionInput in different states: default, disabled, and with minimum options.
+
+
+
+## Paste Support
+
+Demonstrates the paste functionality with different delimiters. Users can paste comma-separated values, newline-separated values, or use custom delimiters.
+
+In key-value mode, the component can also parse key-value pairs from pasted text. For example:
+- `KEY=value`
+- `NODE_ENV:production`
+
+
+
+## Customization
+
+Examples of different customization options like placeholders, move buttons, remove buttons, and key-value pairs.
+
+
+
+## Usage
+
+### Standard Options
+
+```tsx
+import { MultiOptionInput } from "@calcom/ui/components/form/inputs/MultiOptionInput";
+import { useForm, FormProvider } from "react-hook-form";
+
+type FormValues = {
+ options: Array<{ label: string; id: string }>;
+};
+
+const MyForm = () => {
+ const methods = useForm();
+
+ return (
+
+
+
+ );
+};
+```
+
+### Key-Value Pairs
+
+```tsx
+import { MultiOptionInput } from "@calcom/ui/components/form/inputs/MultiOptionInput";
+import { useForm, FormProvider } from "react-hook-form";
+
+type FormValues = {
+ envVars: Array<{ label: string; value: string; id: string }>;
+};
+
+const EnvVarsForm = () => {
+ const methods = useForm();
+
+ return (
+
+
+
+ );
+};
+```
diff --git a/apps/ui-playground/content/design/components/multiInputField.customization.tsx b/apps/ui-playground/content/design/components/multiInputField.customization.tsx
new file mode 100644
index 0000000000..efa4bf1e44
--- /dev/null
+++ b/apps/ui-playground/content/design/components/multiInputField.customization.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { RenderComponentWithSnippet } from "@/app/components/render";
+import { useForm, FormProvider } from "react-hook-form";
+
+import { MultiOptionInput } from "@calcom/ui/components/form";
+
+type FormValues = {
+ customPlaceholders: Array<{ label: string; id: string }>;
+ noMoveButtons: Array<{ label: string; id: string }>;
+ customLabel: Array<{ label: string; id: string }>;
+ keyValuePairs: Array<{ label: string; value: string; id: string }>;
+};
+
+export const CustomizationExample: React.FC = () => {
+ const methods = useForm();
+
+ return (
+
+
+
+
+
+
Key-Value Pairs
+
Allows inputting keys and values
+
+ fieldArrayName="keyValuePairs"
+ keyValueMode
+ keyLabel="Environment Variable"
+ valueLabel="Value"
+ optionPlaceholders={["NODE_ENV", "PORT", "DATABASE_URL"]}
+ valuePlaceholders={["production", "3000", "postgres://..."]}
+ defaultNumberOfOptions={3}
+ keyValueDelimiters={[":", "="]}
+ />
+
+
+
+
Custom Placeholders
+
+ fieldArrayName="customPlaceholders"
+ optionPlaceholders={["Enter your name", "Enter your email", "Enter your phone"]}
+ defaultNumberOfOptions={3}
+ />
+
+
+
+
Without Move Buttons
+
+ fieldArrayName="noMoveButtons"
+ optionPlaceholders={["Static option 1", "Static option 2"]}
+ defaultNumberOfOptions={2}
+ showMoveButtons={false}
+ />
+
+
+
+
Custom Add Button Label
+
+ fieldArrayName="customLabel"
+ optionPlaceholders={["Social media link"]}
+ defaultNumberOfOptions={1}
+ addOptionLabel="Add another social media link"
+ />
+
+
+
+
+
+ );
+};
diff --git a/apps/ui-playground/content/design/components/multiInputField.paste.tsx b/apps/ui-playground/content/design/components/multiInputField.paste.tsx
new file mode 100644
index 0000000000..ea6fe03f96
--- /dev/null
+++ b/apps/ui-playground/content/design/components/multiInputField.paste.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { RenderComponentWithSnippet } from "@/app/components/render";
+import { useForm, FormProvider } from "react-hook-form";
+
+import { MultiOptionInput } from "@calcom/ui/components/form";
+
+type FormValues = {
+ newlineOptions: Array<{ label: string; id: string }>;
+ commaOptions: Array<{ label: string; id: string }>;
+ customOptions: Array<{ label: string; id: string }>;
+ keyValueOptions: Array<{ label: string; value: string; id: string }>;
+};
+
+export const PasteExample: React.FC = () => {
+ const methods = useForm();
+
+ return (
+
+
+
+
+
+
Default Delimiters (Newline and Comma)
+
+ Try pasting: “Option 1, Option 2” or multiple lines
+
+
+ fieldArrayName="newlineOptions"
+ optionPlaceholders={["Paste here..."]}
+ defaultNumberOfOptions={1}
+ />
+
+
+
+
Comma Only Delimiter
+
Try pasting: “First, Second, Third”
+
+ fieldArrayName="commaOptions"
+ optionPlaceholders={["Paste here..."]}
+ defaultNumberOfOptions={1}
+ pasteDelimiters={[","]}
+ />
+
+
+
+
Key-Value Pair Paste Support
+
+ Try pasting: “NODE_ENV=production” or “KEY1:value1, KEY2:value2”
+
+
+ fieldArrayName="keyValueOptions"
+ keyValueMode
+ optionPlaceholders={["Key..."]}
+ valuePlaceholders={["Value..."]}
+ defaultNumberOfOptions={1}
+ keyValueDelimiters={[":", "="]}
+ />
+
+
+
+
Custom Delimiters (Semicolon and Pipe)
+
Try pasting: “One;Two|Three”
+
+ fieldArrayName="customOptions"
+ optionPlaceholders={["Paste here..."]}
+ defaultNumberOfOptions={1}
+ pasteDelimiters={[";", "|"]}
+ />
+
+
+
+
+
+ );
+};
diff --git a/apps/ui-playground/content/design/components/multiInputField.states.tsx b/apps/ui-playground/content/design/components/multiInputField.states.tsx
new file mode 100644
index 0000000000..82f4c20678
--- /dev/null
+++ b/apps/ui-playground/content/design/components/multiInputField.states.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { RenderComponentWithSnippet } from "@/app/components/render";
+import { useForm, FormProvider } from "react-hook-form";
+
+import { MultiOptionInput } from "@calcom/ui/components/form";
+
+type FormValues = {
+ defaultOptions: Array<{ label: string; id: string }>;
+ disabledOptions: Array<{ label: string; id: string }>;
+ minOptions: Array<{ label: string; id: string }>;
+};
+
+export const StatesExample: React.FC = () => {
+ const methods = useForm();
+
+ return (
+
+
+
+
+
+
Default State
+
+ fieldArrayName="defaultOptions"
+ optionPlaceholders={["First option", "Second option", "Third option"]}
+ defaultNumberOfOptions={3}
+ />
+
+
+
+
Disabled State
+
+ fieldArrayName="disabledOptions"
+ optionPlaceholders={["Disabled option 1", "Disabled option 2"]}
+ defaultNumberOfOptions={2}
+ disabled
+ />
+
+
+
+
Minimum Options (2)
+
+ fieldArrayName="minOptions"
+ optionPlaceholders={["Required option 1", "Required option 2", "Optional option"]}
+ defaultNumberOfOptions={3}
+ minOptions={2}
+ />
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/components/form/index.ts b/packages/ui/components/form/index.ts
index ede0f2a7dc..bdd055ee30 100644
--- a/packages/ui/components/form/index.ts
+++ b/packages/ui/components/form/index.ts
@@ -14,6 +14,8 @@ export {
FilterSearchField,
} from "./inputs/Input";
+export { MultiOptionInput } from "./inputs/MultiOptionInput";
+
export { InputFieldWithSelect } from "./inputs/InputFieldWithSelect";
export type { InputFieldProps, InputProps } from "./inputs/types";
export { InputField, Input, TextField, inputStyles } from "./inputs/TextField";
diff --git a/packages/ui/components/form/inputs/MultiOptionInput.tsx b/packages/ui/components/form/inputs/MultiOptionInput.tsx
new file mode 100644
index 0000000000..5639bc3e90
--- /dev/null
+++ b/packages/ui/components/form/inputs/MultiOptionInput.tsx
@@ -0,0 +1,312 @@
+import { useAutoAnimate } from "@formkit/auto-animate/react";
+import {
+ useFieldArray,
+ useFormContext,
+ type FieldValues,
+ type Path,
+ type PathValue,
+ type ArrayPath,
+} from "react-hook-form";
+import { v4 as uuidv4 } from "uuid";
+
+import classNames from "@calcom/ui/classNames";
+import { Button } from "@calcom/ui/components/button";
+import { TextField } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+
+export interface Option {
+ label: string;
+ value?: string;
+ id?: string;
+ [key: string]: any;
+}
+
+interface MultiOptionInputProps {
+ /**
+ * The field array name in the form schema
+ * @example "fields.0.options" or "questions"
+ */
+ fieldArrayName: ArrayPath;
+ /**
+ * Whether to display as key-value pairs
+ * @default false
+ */
+ keyValueMode?: boolean;
+ /**
+ * Label for the key field in key-value mode
+ * @default "Key"
+ */
+ keyLabel?: string;
+ /**
+ * Label for the value field in key-value mode
+ * @default "Value"
+ */
+ valueLabel?: string;
+ /**
+ * Custom regex pattern to validate keys
+ */
+ keyPattern?: string;
+ addOptionLabel?: string;
+ optionPlaceholders?: string[];
+ /**
+ * Placeholders for value fields in key-value mode
+ */
+ valuePlaceholders?: string[];
+ defaultNumberOfOptions?: number;
+ /**
+ * Delimiters to split pasted text on. Defaults to ["\n", ","]
+ * For key-value pairs, will try to split on ":" or "=" between key and value
+ */
+ pasteDelimiters?: string[];
+ /**
+ * Delimiters to split key-value pairs on. Defaults to [":", "="]
+ */
+ keyValueDelimiters?: string[];
+ /**
+ * Whether to show move up/down buttons. Defaults to true
+ */
+ showMoveButtons?: boolean;
+ /**
+ * Whether to show remove button. Defaults to true when there is more than 1 option
+ */
+ showRemoveButton?: boolean;
+ /**
+ * Minimum number of options required. Defaults to 1
+ */
+ minOptions?: number;
+ disabled?: boolean;
+}
+
+export const MultiOptionInput = ({
+ fieldArrayName,
+ keyValueMode = false,
+ keyLabel = "Key",
+ valueLabel = "Value",
+ keyPattern,
+ addOptionLabel = "Add an option",
+ optionPlaceholders = ["Option 1", "Option 2", "Option 3", "Option 4"],
+ valuePlaceholders = ["Value 1", "Value 2", "Value 3", "Value 4"],
+ defaultNumberOfOptions = 4,
+ pasteDelimiters = ["\n", ","],
+ keyValueDelimiters = [":", "="],
+ showMoveButtons = true,
+ showRemoveButton: _showRemoveButton,
+ minOptions = 1,
+ disabled = false,
+}: MultiOptionInputProps) => {
+ const [animationRef] = useAutoAnimate();
+ const { control } = useFormContext();
+
+ const {
+ fields: options,
+ append: appendOption,
+ remove: removeOption,
+ move: moveOption,
+ replace: replaceOptions,
+ } = useFieldArray({
+ control,
+ name: fieldArrayName,
+ });
+
+ // Initialize with default options if none exist
+ if (options.length === 0) {
+ const defaultOptions = Array(defaultNumberOfOptions)
+ .fill(0)
+ .map(() => ({ label: "", value: "", id: uuidv4() }));
+ replaceOptions(defaultOptions as PathValue>);
+ }
+
+ const showRemoveButton = _showRemoveButton ?? options.length > minOptions;
+
+ const handlePasteInOptionAtIndex = (event: React.ClipboardEvent, optionIndex: number) => {
+ const paste = event.clipboardData.getData("text");
+ // Split on any of the delimiters
+ const delimiterRegex = new RegExp(`[${pasteDelimiters.join("")}]+`);
+ const keyValueRegex = new RegExp(
+ `([^${keyValueDelimiters.join("")}]+)([${keyValueDelimiters.join("")}])(.+)`
+ );
+
+ const optionsBeingPasted = paste
+ .split(delimiterRegex)
+ .map((optionText) => optionText.trim())
+ .filter((optionText) => optionText)
+ .map((optionText) => {
+ // If in key-value mode, try to parse as key-value
+ if (keyValueMode) {
+ const match = optionText.match(keyValueRegex);
+ if (match) {
+ return {
+ label: match[1].trim(),
+ value: match[3].trim(),
+ id: uuidv4(),
+ };
+ }
+ return { label: optionText, value: "", id: uuidv4() };
+ }
+ return { label: optionText, id: uuidv4() };
+ });
+
+ if (optionsBeingPasted.length === 1) {
+ // If there is only one option, let the default paste behavior handle it
+ return;
+ }
+
+ // Don't allow pasting that value, as we would update the options through state update
+ event.preventDefault();
+
+ // Replace the current option with the first pasted option
+ const updatedOptions = [...options] as PathValue>[];
+ updatedOptions[optionIndex] = optionsBeingPasted[0] as PathValue>;
+
+ // Insert the rest of the options after the current option
+ updatedOptions.splice(
+ optionIndex + 1,
+ 0,
+ ...(optionsBeingPasted.slice(1) as PathValue>[])
+ );
+ replaceOptions(updatedOptions as PathValue>);
+ };
+
+ const addOption = () => {
+ appendOption({ label: "", value: "", id: uuidv4() } as PathValue>);
+ };
+
+ const handleRemoveOption = (index: number) => {
+ if (options.length <= minOptions) return;
+ removeOption(index);
+ };
+
+ const moveUp = (index: number) => {
+ if (index === 0) return;
+ moveOption(index, index - 1);
+ };
+
+ const moveDown = (index: number) => {
+ if (index === options.length - 1) return;
+ moveOption(index, index + 1);
+ };
+
+ return (
+
+ {keyValueMode && (
+
+
+ {keyLabel}
+
+
+ {valueLabel}
+
+ {/* Space for buttons */}
+
+
+ )}
+
+ {options.map((option, index) => (
+ handlePasteInOptionAtIndex(event, index)}>
+ {keyValueMode ? (
+ // Key-value pair mode
+
+
+ )}
+ />
+
+
+ )}
+ addOnSuffix={
+ showRemoveButton ? (
+ handleRemoveOption(index)}
+ aria-label="Remove option"
+ disabled={disabled}>
+
+
+ ) : null
+ }
+ />
+
+
+ ) : (
+ // Standard mode
+
+ )}
+ addOnSuffix={
+ showRemoveButton ? (
+ handleRemoveOption(index)}
+ aria-label="Remove option"
+ disabled={disabled}>
+
+
+ ) : null
+ }
+ />
+
+ )}
+ {showMoveButtons && (
+
+ moveUp(index)}
+ disabled={index === 0 || disabled}
+ className={classNames(
+ "bg-default text-muted hover:text-emphasis invisible flex h-6 w-6 items-center justify-center rounded-t-md border p-1 transition-all hover:border-transparent hover:shadow group-hover:visible",
+ index === 0 ? "cursor-not-allowed opacity-30" : "cursor-pointer"
+ )}>
+
+
+ moveDown(index)}
+ disabled={index === options.length - 1 || disabled}
+ className={classNames(
+ "bg-default text-muted hover:text-emphasis invisible -mt-px flex h-6 w-6 items-center justify-center rounded-b-md border p-1 transition-all hover:border-transparent hover:shadow group-hover:visible",
+ index === options.length - 1 ? "cursor-not-allowed opacity-30" : "cursor-pointer"
+ )}>
+
+
+
+ )}
+
+ ))}
+
+
+
+ {addOptionLabel}
+
+
+
+ );
+};
diff --git a/packages/ui/package.json b/packages/ui/package.json
index e9580faa9c..922768882b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -82,6 +82,7 @@
"@storybook/react": "^7.6.3",
"@tanstack/react-query": "^5.17.15",
"@tanstack/react-table": "^8.20.6",
+ "@types/uuid": "^10.0.0",
"@wojtekmaj/react-daterange-picker": "^3.3.1",
"class-variance-authority": "^0.4.0",
"cmdk": "^0.2.0",
@@ -96,7 +97,8 @@
"react-hook-form": "^7.43.3",
"react-inlinesvg": "^4.1.3",
"react-select": "^5.7.0",
- "tailwind-merge": "^1.13.2"
+ "tailwind-merge": "^1.13.2",
+ "uuid": "^11.1.0"
},
"devDependencies": {
"@calcom/config": "*",
diff --git a/yarn.lock b/yarn.lock
index 5c04c67343..f0cc37c981 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3723,6 +3723,7 @@ __metadata:
"@testing-library/user-event": ^14.6.1
"@types/react": 18.0.26
"@types/react-dom": ^18.0.9
+ "@types/uuid": ^10.0.0
"@wojtekmaj/react-daterange-picker": ^3.3.1
class-variance-authority: ^0.4.0
cmdk: ^0.2.0
@@ -3744,6 +3745,7 @@ __metadata:
react-select: ^5.7.0
tailwind-merge: ^1.13.2
typescript: ^5.8.2
+ uuid: ^11.1.0
languageName: unknown
linkType: soft
@@ -17604,6 +17606,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/uuid@npm:^10.0.0":
+ version: 10.0.0
+ resolution: "@types/uuid@npm:10.0.0"
+ checksum: e3958f8b0fe551c86c14431f5940c3470127293280830684154b91dc7eb3514aeb79fe3216968833cf79d4d1c67f580f054b5be2cd562bebf4f728913e73e944
+ languageName: node
+ linkType: hard
+
"@types/uuid@npm:^9.0.1":
version: 9.0.8
resolution: "@types/uuid@npm:9.0.8"
@@ -45694,7 +45703,7 @@ __metadata:
languageName: node
linkType: hard
-"uuid@npm:^11.0.5":
+"uuid@npm:^11.0.5, uuid@npm:^11.1.0":
version: 11.1.0
resolution: "uuid@npm:11.1.0"
bin: