mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): Add cool Input component (#10196)
## Changes 🏗️ <img width="400" alt="Screenshot 2025-06-19 at 18 17 53" src="https://github.com/user-attachments/assets/ad690d75-4ce0-4f50-9774-1f9d07cd5934" /> <img width="400" alt="Screenshot 2025-06-19 at 18 17 56" src="https://github.com/user-attachments/assets/97d59e18-76c8-46d1-9b8f-87058bc1ab86" /> ### 📙 Overview - New Input component (`components/atoms/Input/`) - Multiple input types with smart formatting: - `type="text"` → Basic text input with no filtering - `type="email"` → Email input (no character filtering) - `type="password"` → Password input with masking - `type="number"` → Number input with character filtering (digits, decimal, minus) - `type="amount"` → Formatted number input with comma separators and decimal limiting - `type="tel"` → Phone input allowing only `+()[] ` and digits - `type="url"` → URL input (no character filtering) ### 📙 Smart formatting features - Amount type: `1000` → `1,000`, `1234.567` → `1,234.56` (with `decimalCount={2}`) - Number type: `abc123def` → `123`, `12.34.56` → `12.3456` - Phone type: `abc+1(555)def` → `+1(555)` ### 📙 Other - Error state with `error` prop - shows red border and error message below input - Label control with `hideLabel` prop for accessibility - Decimal precision control via `decimalCount` prop (amount type only, default: 4) ### 📙 Architecture - **Clean separation**: `Input.tsx` (render), `useInput.ts` (logic), `helpers.ts` (utilities) - **Comprehensive Storybook stories** with usage examples and documentation ### 📙 Examples ```tsx // Basic inputs <Input type="text" label="Full Name" /> <Input type="email" label="Email" error="Invalid email" /> // Formatted inputs <Input type="amount" label="Price" decimalCount={2} /> <Input type="tel" label="Phone" placeholder="+1 (555) 123-4567" /> // Number input (unlimited decimals) <Input type="number" label="Score" /> ``` ## Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: **Test Plan:** - [x] **Basic functionality**: Text input, label visibility, disabled state, error display - [x] **Number validation**: Character filtering (`abc123` → `123`), decimal handling, negative numbers - [x] **Amount formatting**: Comma insertion (`1000` → `1,000`), decimal limiting with `decimalCount` - [x] **Phone filtering**: Only allows `+()[] ` and digits (`abc555def` → `555`) - [x] **Email/Password/URL**: No character filtering, proper input types - [x] **Edge cases**: Starting with `.` or `-`, multiple decimals, accessibility with hidden labels - [x] **Storybook stories**: All variants documented with interactive examples ```
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { Input } from "./Input";
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
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<typeof meta>;
|
||||
|
||||
// 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 (
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<Input label="Full Name" type="text" placeholder="Enter your full name" />
|
||||
<Input label="Email" type="email" placeholder="your.email@example.com" />
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="font-mono text-sm">
|
||||
If type="number" prop is provided, the input will allow only
|
||||
positive or negative numbers. No decimal limiting.
|
||||
</p>
|
||||
<Input label="Age" type="number" placeholder="Enter your age" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="font-mono text-sm">
|
||||
If type="amount" prop is provided, it formats numbers with
|
||||
commas (1000 → 1,000) and limits decimals via decimalCount prop.
|
||||
</p>
|
||||
<Input
|
||||
label="Price"
|
||||
type="amount"
|
||||
placeholder="Enter amount"
|
||||
decimalCount={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="font-mono text-sm">
|
||||
If type="tel" prop is provided, the input will allow only
|
||||
numbers, spaces, parentheses (), plus +, and brackets [].
|
||||
</p>
|
||||
<Input label="Phone" type="tel" placeholder="+1 (555) 123-4567" />
|
||||
</div>
|
||||
<Input label="Website" type="url" placeholder="https://example.com" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<BaseInput
|
||||
className={cn(
|
||||
// Override the default input styles with Figma design
|
||||
"h-[2.875rem] rounded-3xl border border-zinc-200 bg-white px-4 py-2.5 shadow-none",
|
||||
"font-normal leading-6 text-black",
|
||||
"placeholder:font-normal placeholder:text-zinc-400",
|
||||
// Focus and hover states
|
||||
"focus:border-zinc-400 focus:shadow-none focus:outline-none focus:ring-1 focus:ring-zinc-400 focus:ring-offset-0",
|
||||
// Error state
|
||||
error &&
|
||||
"border-2 border-red-500 focus:border-red-500 focus:ring-red-500",
|
||||
className,
|
||||
)}
|
||||
type={props.type}
|
||||
placeholder={placeholder || label}
|
||||
onChange={handleInputChange}
|
||||
{...(hideLabel ? { "aria-label": label } : {})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputWithError = (
|
||||
<div className="flex flex-col gap-1">
|
||||
{input}
|
||||
{error && (
|
||||
<Text variant="small-medium" as="span" className="!text-red-500">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return hideLabel ? (
|
||||
inputWithError
|
||||
) : (
|
||||
<label className="flex flex-col gap-2">
|
||||
<Text variant="body-medium" as="span" className="text-black">
|
||||
{label}
|
||||
</Text>
|
||||
{inputWithError}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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<HTMLInputElement>) {
|
||||
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<HTMLInputElement>;
|
||||
|
||||
args.onChange(syntheticEvent);
|
||||
} else {
|
||||
args.onChange(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { handleInputChange };
|
||||
}
|
||||
Reference in New Issue
Block a user