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:
Ubbe
2025-06-19 20:06:23 +04:00
committed by GitHub
parent a541a3edd7
commit e183be08bd
4 changed files with 344 additions and 0 deletions

View File

@@ -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=&quot;number&quot; 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=&quot;amount&quot; 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=&quot;tel&quot; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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, "");
}

View File

@@ -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 };
}