diff --git a/autogpt_platform/frontend/src/components/atoms/Input/Input.stories.tsx b/autogpt_platform/frontend/src/components/atoms/Input/Input.stories.tsx new file mode 100644 index 0000000000..ccde5622bf --- /dev/null +++ b/autogpt_platform/frontend/src/components/atoms/Input/Input.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { Input } from "./Input"; + +const meta: Meta = { + title: "Atoms/Input", + tags: ["autodocs"], + component: Input, + parameters: { + layout: "centered", + docs: { + description: { + component: + "Input component based on our design system. Built on top of shadcn/ui input with custom styling matching Figma designs.", + }, + }, + }, + argTypes: { + type: { + control: "select", + options: ["text", "email", "password", "number", "amount", "tel", "url"], + description: "Input type", + }, + placeholder: { + control: "text", + description: "Placeholder text", + }, + value: { + control: "text", + description: "The value of the input", + }, + label: { + control: "text", + description: + "Label text (used as placeholder if no placeholder provided)", + }, + disabled: { + control: "boolean", + description: "Disable the input", + }, + hideLabel: { + control: "boolean", + description: "Hide the label", + }, + decimalCount: { + control: "number", + description: + "Number of decimal places allowed (only for amount type). Default is 4.", + }, + error: { + control: "text", + description: "Error message to display below the input", + }, + }, + args: { + placeholder: "Enter text...", + type: "text", + value: "", + disabled: false, + hideLabel: false, + decimalCount: 4, + }, +}; + +export default meta; +type Story = StoryObj; + +// Basic variants +export const Default: Story = { + args: { + placeholder: "Enter your text", + label: "Full Name", + }, +}; + +export const WithoutLabel: Story = { + args: { + label: "Full Name", + hideLabel: true, + }, +}; + +export const Disabled: Story = { + args: { + placeholder: "This field is disabled", + label: "Full Name", + disabled: true, + }, +}; + +export const WithError: Story = { + args: { + label: "Email", + type: "email", + placeholder: "Enter your email", + error: "Please enter a valid email address", + }, +}; + +export const InputTypes: Story = { + render: renderInputTypes, + parameters: { + controls: { + disable: true, + }, + docs: { + description: { + story: + "Complete showcase of all input types with their specific behaviors. Test each input type to verify filtering and formatting works correctly.", + }, + }, + }, +}; + +// Render functions as function declarations +function renderInputTypes() { + return ( +
+ + + +
+

+ If type="number" prop is provided, the input will allow only + positive or negative numbers. No decimal limiting. +

+ +
+
+

+ If type="amount" prop is provided, it formats numbers with + commas (1000 → 1,000) and limits decimals via decimalCount prop. +

+ +
+
+

+ If type="tel" prop is provided, the input will allow only + numbers, spaces, parentheses (), plus +, and brackets []. +

+ +
+ +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx b/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx new file mode 100644 index 0000000000..561d8896f3 --- /dev/null +++ b/autogpt_platform/frontend/src/components/atoms/Input/Input.tsx @@ -0,0 +1,67 @@ +import { Input as BaseInput, type InputProps } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { Text } from "../Text/Text"; +import { useInput } from "./useInput"; + +export interface TextFieldProps extends InputProps { + label: string; + hideLabel?: boolean; + decimalCount?: number; // Only used for type="amount" + error?: string; +} + +export function Input({ + className, + label, + placeholder, + hideLabel = false, + decimalCount, + error, + ...props +}: TextFieldProps) { + const { handleInputChange } = useInput({ ...props, decimalCount }); + + const input = ( + + ); + + const inputWithError = ( +
+ {input} + {error && ( + + {error} + + )} +
+ ); + + return hideLabel ? ( + inputWithError + ) : ( + + ); +} diff --git a/autogpt_platform/frontend/src/components/atoms/Input/helpers.ts b/autogpt_platform/frontend/src/components/atoms/Input/helpers.ts new file mode 100644 index 0000000000..715c834c56 --- /dev/null +++ b/autogpt_platform/frontend/src/components/atoms/Input/helpers.ts @@ -0,0 +1,65 @@ +export const NUMBER_REGEX = /[^0-9.-]/g; +export const PHONE_REGEX = /[^0-9\s()\+\[\]]/g; + +export function formatAmountWithCommas(value: string): string { + if (!value) return value; + + const parts = value.split("."); + const integerPart = parts[0]; + const decimalPart = parts[1]; + + // Add commas to integer part + const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + + // Check if there was a decimal point in the original value + if (value.includes(".")) { + return decimalPart + ? `${formattedInteger}.${decimalPart}` + : `${formattedInteger}.`; + } + + return formattedInteger; +} + +export function filterNumberInput(value: string): string { + let filteredValue = value; + + // Remove all non-numeric characters except . and - + filteredValue = value.replace(NUMBER_REGEX, ""); + + // Handle multiple decimal points - keep only the first one + const parts = filteredValue.split("."); + if (parts.length > 2) { + filteredValue = parts[0] + "." + parts.slice(1).join(""); + } + + // Handle minus signs - only allow at the beginning + if (filteredValue.indexOf("-") > 0) { + const hadMinusAtStart = value.startsWith("-"); + filteredValue = filteredValue.replace(/-/g, ""); + if (hadMinusAtStart) { + filteredValue = "-" + filteredValue; + } + } + + return filteredValue; +} + +export function limitDecimalPlaces( + value: string, + decimalCount: number, +): string { + const [integerPart, decimalPart] = value.split("."); + if (decimalPart && decimalPart.length > decimalCount) { + return `${integerPart}.${decimalPart.substring(0, decimalCount)}`; + } + return value; +} + +export function filterPhoneInput(value: string): string { + return value.replace(PHONE_REGEX, ""); +} + +export function removeCommas(value: string): string { + return value.replace(/,/g, ""); +} diff --git a/autogpt_platform/frontend/src/components/atoms/Input/useInput.ts b/autogpt_platform/frontend/src/components/atoms/Input/useInput.ts new file mode 100644 index 0000000000..b931bc663e --- /dev/null +++ b/autogpt_platform/frontend/src/components/atoms/Input/useInput.ts @@ -0,0 +1,58 @@ +import { InputProps } from "@/components/ui/input"; +import { + filterNumberInput, + filterPhoneInput, + formatAmountWithCommas, + limitDecimalPlaces, + removeCommas, +} from "./helpers"; + +interface ExtendedInputProps extends InputProps { + decimalCount?: number; +} + +export function useInput(args: ExtendedInputProps) { + function handleInputChange(e: React.ChangeEvent) { + const { value } = e.target; + const decimalCount = args.decimalCount ?? 4; + + let processedValue = value; + + if (args.type === "number") { + // Basic number filtering - no decimal limiting + const filteredValue = filterNumberInput(value); + processedValue = filteredValue; + } else if (args.type === "amount") { + // Amount type with decimal limiting and comma formatting + const cleanValue = removeCommas(value); + let filteredValue = filterNumberInput(cleanValue); + filteredValue = limitDecimalPlaces(filteredValue, decimalCount); + + const displayValue = formatAmountWithCommas(filteredValue); + e.target.value = displayValue; + processedValue = filteredValue; // Pass clean value to parent + } else if (args.type === "tel") { + processedValue = filterPhoneInput(value); + } + + // Call onChange with processed value + if (args.onChange) { + // Only create synthetic event if we need to change the value + if (processedValue !== value || args.type === "amount") { + const syntheticEvent = { + ...e, + target: { + ...e.target, + value: processedValue, + }, + } as React.ChangeEvent; + + args.onChange(syntheticEvent); + } else { + args.onChange(e); + } + } + } + + return { handleInputChange }; +}