feat(ui): new Input (#11110)

* feat(input): add new input component and update eslint config

Add new custom input component to replace antd and heroui inputs
Update eslint config to enforce using the new input component

* feat(input): refactor input component to support compound pattern

Add new Input component with support for Password and Button variants through compound pattern. Move input implementation to new directory structure and enhance with label and caption support. Remove old input implementation.

* refactor(input): consolidate input components and update exports

Move input component files to lowercase directory and simplify structure
Remove unused button and password input components
Update exports in components index file

* refactor: replace antd Input with @cherrystudio/ui Input across components

* feat(primitives): add textarea component to ui primitives

* feat(primitives): add input-group component with variants and controls

build: update @radix-ui/react-slot dependency to v1.2.4

* refactor(ui): simplify input component and update usage

Remove complex Input component implementation and replace with simpler version
Update components to use new Input and Textarea components from ui package

* feat(ui): add composite input component and utility functions

- Introduce new CompositeInput component with variants and password toggle
- Add utility functions for null/undefined conversion
- Export new components and types from index
- Update input props interface and usage in input-group

* feat(Input): refactor CompositeInput component and add stories

- Refactor CompositeInput component with improved variants and styling
- Add comprehensive Storybook stories for Input, InputGroup and CompositeInput components
- Implement password toggle functionality and button variants
- Include accessibility features and interactive examples

* feat(input): improve disabled state styling and behavior

- Add disabled state variants for input components
- Ensure password toggle button respects disabled state
- Update disabled styling for better visual consistency
- Add storybook examples for disabled password inputs

* feat(input): add validation states and form examples

- Implement validation states for input components
- Add real-time validation examples
- Create form validation demos for different input types
- Update styling for disabled and invalid states

* feat(input): add prefix support for email variant input

Add prefix variants styling and prefix prop to CompositeInput component to support email inputs with fixed prefixes. Update stories to demonstrate various prefix use cases and interactive examples.

* refactor(Input): simplify content rendering logic by removing useMemo hooks

The startContent and endContent memoized values were removed and their logic was inlined directly in the JSX. This makes the code more straightforward and removes unnecessary memoization overhead since the calculations are simple.

* feat(Input): add select variant to CompositeInput component

Add new 'select' variant to CompositeInput component with support for select dropdown groups and items. Includes styling variants, type exports, and comprehensive storybook examples demonstrating various use cases like currency input, URL with protocol, phone with country code, and temperature with unit selectors.

* Revert "refactor: replace antd Input with @cherrystudio/ui Input across components"

This reverts commit f7f689b326.

* fix(CompositeInput): handle missing props gracefully by returning null

Add null checks for email and select variants to prevent rendering issues when required props are missing

* fix(Input): adjust select prefix and trigger styling

Update select prefix variants to remove redundant padding and simplify size variants. Add new selectTriggerVariants for consistent styling across sizes.

* feat(storybook): add playground story for InputGroup component

Add interactive playground story with controls for all InputGroup props including addons, button variants and input types

* style(primitives): remove redundant border radius from input group variants

* style(input): adjust button and label variant styling

Refactor variant classes to use string literals instead of arrays for better readability

* refactor(Input): simplify variant class strings in input component

---------

Co-authored-by: MyPrototypeWhat <daoquqiexing@gmail.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
This commit is contained in:
Phantom 2025-12-01 17:15:07 +08:00 committed by GitHub
parent 4a38fd6ebc
commit b75c10d9f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 3835 additions and 0 deletions

View File

@ -0,0 +1,4 @@
import type { CompositeInputProps, SelectGroup, SelectItem } from './input'
import { CompositeInput } from './input'
export { CompositeInput, type CompositeInputProps, type SelectGroup, type SelectItem }

View File

@ -0,0 +1,371 @@
import { cn, toUndefinedIfNull } from '@cherrystudio/ui/utils'
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import { Edit2Icon, EyeIcon, EyeOffIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { useCallback, useMemo, useState } from 'react'
import type { InputProps } from '../../primitives/input'
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../../primitives/input-group'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from '../../primitives/select'
const inputGroupVariants = cva(
[
'h-auto',
'rounded-xs',
'has-[[data-slot=input-group-control]:focus-visible]:ring-ring/40',
'has-[[data-slot=input-group-control]:focus-visible]:border-[#3CD45A]'
],
{
variants: {
disabled: {
false: null,
true: ['bg-background-subtle', 'border-border-hover', 'cursor-not-allowed']
}
},
defaultVariants: {
disabled: false
}
}
)
const inputVariants = cva(['p-0', 'h-fit', 'min-w-0'], {
variants: {
size: {
sm: ['text-sm', 'leading-4'],
md: ['leading-4.5'],
lg: ['text-lg', 'leading-5']
},
variant: {
default: [],
button: [],
email: [],
select: []
},
disabled: {
false: null,
true: ['text-foreground/40', 'placeholder:text-foreground/40', 'disabled:opacity-100']
}
},
defaultVariants: {
size: 'md',
variant: 'default',
disabled: false
}
})
const inputWrapperVariants = cva(['flex', 'flex-1', 'items-center', 'gap-2'], {
variants: {
size: {
sm: ['p-3xs'],
// Why only the md size is fixed height???
md: ['p-3xs', 'h-5.5', 'box-content'],
lg: ['px-2xs', 'py-3xs']
},
variant: {
default: [],
button: 'border-r-[1px]',
email: [],
select: []
},
disabled: {
false: null,
true: 'border-background-subtle'
}
},
defaultVariants: {
disabled: false
}
})
const iconVariants = cva([], {
variants: {
size: {
sm: 'size-4.5',
md: 'size-5',
lg: 'size-6'
},
disabled: {
false: null,
true: 'text-foreground/40'
}
},
defaultVariants: {
size: 'md',
disabled: false
}
})
const iconButtonVariants = cva(['text-foreground/60 cursor-pointer transition-colors', 'hover:shadow-none'], {
variants: {
disabled: {
false: null,
true: []
}
},
defaultVariants: {
disabled: false
}
})
const buttonVariants = cva(
['py-3xs', 'flex flex-col', 'text-foreground/60 cursor-pointer transition-colors', 'hover:shadow-none'],
{
variants: {
size: {
sm: 'px-3xs',
md: 'px-3xs',
lg: 'px-2xs'
},
disabled: {
false: null,
true: ['pointer-events-none']
}
},
defaultVariants: {
size: 'md',
disabled: false
}
}
)
const buttonLabelVariants = cva([], {
variants: {
size: {
// TODO: p/font-family, p/letter-spacing ... p?
sm: 'text-sm leading-4',
md: 'leading-4.5',
lg: 'text-lg leading-5 tracking-normal'
},
disabled: {
false: null,
true: ['text-foreground/40']
}
},
defaultVariants: {
size: 'md',
disabled: false
}
})
const prefixVariants = cva(['font-medium', 'border-r-[1px]', 'text-foreground/60'], {
variants: {
size: {
// TODO: semantic letter-spacing
sm: ['text-sm leading-4', 'p-3xs'],
md: ['leading-4.5', 'p-3xs'],
lg: ['leading-5 tracking-normal', 'px-2xs py-3xs']
},
disabled: {
false: null,
true: 'text-foreground/40'
}
},
defaultVariants: {
size: 'md',
disabled: false
}
})
const selectPrefixVariants = cva(['font-medium', 'border-r-[1px]', 'text-foreground/60', 'p-0'], {
variants: {
size: {
// TODO: semantic letter-spacing
sm: 'text-sm leading-4',
md: 'leading-4.5',
lg: 'leading-5 tracking-normal'
},
disabled: {
false: null,
true: 'text-foreground/40'
}
},
defaultVariants: {
size: 'md',
disabled: false
}
})
const selectTriggerVariants = cva(
[
'border-none box-content pl-3 aria-expanded:border-none aria-expanded:ring-0 bg-transparent',
'*:data-[slot=select-value]:text-foreground',
'[&_svg]:text-secondary-foreground!'
],
{
variants: {
size: {
sm: ['h-5', 'pl-6 pr-3xs py-3', '*:data-[slot=select-value]:text-sm'],
md: ['h-5', 'pl-6 pr-3xs py-[13px]'],
lg: ['h-6', 'pl-7 pr-2xs py-3', '*:data-[slot=select-value]:text-lg']
}
}
}
)
const selectTriggerLabelVariants = cva([], {
variants: {
size: {
// TODO: p/font-family, p/letter-spacing ... p?
sm: 'text-sm leading-4',
md: 'leading-4.5',
lg: 'text-lg leading-5 tracking-normal'
}
}
})
function ShowPasswordButton({
type,
setType,
size = 'md',
disabled = false
}: {
type: 'text' | 'password'
setType: React.Dispatch<React.SetStateAction<'text' | 'password'>>
size: VariantProps<typeof inputVariants>['size']
disabled: boolean
}) {
const togglePassword = useCallback(() => {
if (disabled) return
if (type === 'password') {
setType('text')
} else if (type === 'text') {
setType('password')
}
}, [disabled, setType, type])
const iconClassName = iconVariants({ size, disabled })
return (
<InputGroupButton onClick={togglePassword} disabled={disabled} className={iconButtonVariants({ disabled })}>
{type === 'text' && <EyeIcon className={iconClassName} />}
{type === 'password' && <EyeOffIcon className={iconClassName} />}
</InputGroupButton>
)
}
interface SelectItem {
label: ReactNode
value: string
}
interface SelectGroup {
label: ReactNode
items: SelectItem[]
}
interface CompositeInputProps
extends Omit<InputProps, 'size' | 'disabled' | 'prefix'>,
VariantProps<typeof inputVariants> {
buttonProps?: {
label?: ReactNode
onClick: React.DOMAttributes<HTMLButtonElement>['onClick']
}
prefix?: ReactNode
selectProps?: {
groups: SelectGroup[]
placeholder?: string
}
}
function CompositeInput({
type = 'text',
size = 'md',
variant = 'default',
disabled = false,
buttonProps,
prefix,
selectProps,
className,
...rest
}: CompositeInputProps) {
const isPassword = type === 'password'
const [htmlType, setHtmlType] = useState<'text' | 'password'>('password')
const buttonContent = useMemo(() => {
if (buttonProps === undefined) {
console.warn("CustomizedInput: 'button' variant requires a 'button' prop to be provided.")
return null
} else {
return (
<InputGroupButton className={buttonVariants({ size, disabled })} onClick={buttonProps.onClick}>
<div className={buttonLabelVariants({ size, disabled })}>{buttonProps.label}</div>
</InputGroupButton>
)
}
}, [buttonProps, disabled, size])
const emailContent = useMemo(() => {
if (!prefix) {
console.warn('CompositeInput: "email" variant requires a "prefix" prop to be provided.')
return null
} else {
return <div className={prefixVariants({ size, disabled })}>{prefix}</div>
}
}, [disabled, prefix, size])
const selectContent = useMemo(() => {
if (!selectProps) {
console.warn('CompositeInput: "select" variant requires a "selectProps" prop to be provided.')
return null
} else {
return (
<div className={selectPrefixVariants({ size, disabled })}>
<Select>
<SelectTrigger className={selectTriggerVariants({ size })}>
<SelectValue placeholder={selectProps.placeholder} className={selectTriggerLabelVariants({ size })} />
</SelectTrigger>
<SelectContent>
{selectProps.groups.map((group, index) => (
<SelectGroup key={index}>
<SelectLabel>{group.label}</SelectLabel>
{group.items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
)
}
}, [disabled, selectProps, size])
return (
<InputGroup className={inputGroupVariants({ disabled })}>
{variant === 'email' && emailContent}
{variant === 'select' && selectContent}
<div className={inputWrapperVariants({ size, variant, disabled })}>
<InputGroupInput
type={isPassword ? htmlType : type}
disabled={toUndefinedIfNull(disabled)}
className={cn(inputVariants({ size, variant, disabled }), className)}
{...rest}
/>
{(variant === 'default' || variant === 'button') && (
<>
<InputGroupAddon className="p-0">
<Edit2Icon className={iconVariants({ size, disabled })} />
</InputGroupAddon>
<InputGroupAddon align="inline-end" className="p-0">
<ShowPasswordButton type={htmlType} setType={setHtmlType} size={size} disabled={!!disabled} />
</InputGroupAddon>
</>
)}
</div>
{variant === 'button' && buttonContent}
</InputGroup>
)
}
export { CompositeInput, type CompositeInputProps, type SelectGroup, type SelectItem }

View File

@ -49,6 +49,12 @@ export { HelpTooltip, type IconTooltipProps, InfoTooltip, WarnTooltip } from './
// ImageToolButton
export { default as ImageToolButton } from './composites/ImageToolButton'
// Sortable
export {
CompositeInput,
type CompositeInputProps,
type SelectGroup as CompositeInputSelectGroup,
type SelectItem as CompositeInputSelectItem
} from './composites/Input'
export { Sortable } from './composites/Sortable'
/* Shadcn Primitive Components */
@ -58,6 +64,8 @@ export * from './primitives/checkbox'
export * from './primitives/combobox'
export * from './primitives/command'
export * from './primitives/dialog'
export * from './primitives/input'
export * from './primitives/input-group'
export * from './primitives/kbd'
export * from './primitives/pagination'
export * from './primitives/popover'
@ -65,3 +73,4 @@ export * from './primitives/radioGroup'
export * from './primitives/select'
export * from './primitives/shadcn-io/dropzone'
export * from './primitives/tabs'
export * from './primitives/textarea'

View File

@ -0,0 +1,147 @@
import { Button } from '@cherrystudio/ui/components/primitives/button'
import type { InputProps } from '@cherrystudio/ui/components/primitives/input'
import { Input } from '@cherrystudio/ui/components/primitives/input'
import { Textarea } from '@cherrystudio/ui/components/primitives/textarea'
import { cn } from '@cherrystudio/ui/utils/index'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group border-input bg-background relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
'block-end': 'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5'
}
},
defaultVariants: {
align: 'inline-start'
}
}
)
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva('text-sm shadow-none flex gap-2 items-center', {
variants: {
size: {
xs: "h-6 gap-1 px-2 [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
'icon-xs': 'size-6 p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0'
}
},
defaultVariants: {
size: 'xs'
}
})
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> & VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({ className, ...props }: InputProps) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className
)}
{...props}
/>
)
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className
)}
{...props}
/>
)
}
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea }

View File

@ -0,0 +1,23 @@
import { cn } from '@cherrystudio/ui/utils'
import * as React from 'react'
interface InputProps extends React.ComponentProps<'input'> {}
function Input({ className, type, ...props }: InputProps) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'disabled:opacity-50',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
)
}
export { Input, type InputProps }

View File

@ -0,0 +1,17 @@
import { cn } from '@cherrystudio/ui/utils'
import * as React from 'react'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -8,3 +8,25 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Converts `null` to `undefined`, otherwise returns the input value.
* Useful when interfacing with APIs or libraries that treat `null` and `undefined` differently.
* @param data - The value that might be `null`
* @returns `undefined` if `data` is `null`, otherwise the original value
*/
export const toUndefinedIfNull = <T>(data: T | null): T | undefined => {
if (data === null) return undefined
else return data
}
/**
* Converts `undefined` to `null`, otherwise returns the input value.
* Handy for ensuring consistent representation of absent values.
* @param data - The value that might be `undefined`
* @returns `null` if `data` is `undefined`, otherwise the original value
*/
export const toNullIfUndefined = <T>(data: T | undefined): T | null => {
if (data === undefined) return null
else return data
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,629 @@
import type { Meta, StoryObj } from '@storybook/react'
import { Mail, Search, User } from 'lucide-react'
import { useState } from 'react'
import { Input } from '../../../src/components/primitives/input'
const meta: Meta<typeof Input> = {
title: 'Components/Primitives/Input',
component: Input,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A basic text input component with focus states, error handling, and file upload support. Built with accessibility in mind and styled with Tailwind CSS.'
}
}
},
tags: ['autodocs'],
argTypes: {
type: {
control: { type: 'select' },
options: ['text', 'email', 'password', 'number', 'search', 'tel', 'url', 'date', 'time', 'file'],
description: 'The type of the input'
},
placeholder: {
control: { type: 'text' },
description: 'Placeholder text'
},
disabled: {
control: { type: 'boolean' },
description: 'Whether the input is disabled'
},
className: {
control: { type: 'text' },
description: 'Additional CSS classes'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Default
export const Default: Story = {
args: {
placeholder: 'Enter text...'
}
}
// With Value
export const WithValue: Story = {
args: {
defaultValue: 'Hello World',
placeholder: 'Enter text...'
}
}
// Types
export const TextType: Story = {
args: {
type: 'text',
placeholder: 'Enter text...'
}
}
export const EmailType: Story = {
args: {
type: 'email',
placeholder: 'Enter email...'
}
}
export const PasswordType: Story = {
args: {
type: 'password',
placeholder: 'Enter password...'
}
}
export const NumberType: Story = {
args: {
type: 'number',
placeholder: 'Enter number...'
}
}
export const SearchType: Story = {
args: {
type: 'search',
placeholder: 'Search...'
}
}
// All Input Types
export const AllInputTypes: Story = {
render: () => (
<div className="flex w-80 flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-medium">Text</label>
<Input type="text" placeholder="Enter text..." />
</div>
<div>
<label className="mb-1 block text-sm font-medium">Email</label>
<Input type="email" placeholder="email@example.com" />
</div>
<div>
<label className="mb-1 block text-sm font-medium">Password</label>
<Input type="password" placeholder="Enter password..." />
</div>
<div>
<label className="mb-1 block text-sm font-medium">Number</label>
<Input type="number" placeholder="0" />
</div>
<div>
<label className="mb-1 block text-sm font-medium">Search</label>
<Input type="search" placeholder="Search..." />
</div>
<div>
<label className="mb-1 block text-sm font-medium">URL</label>
<Input type="url" placeholder="https://example.com" />
</div>
<div>
<label className="mb-1 block text-sm font-medium">Tel</label>
<Input type="tel" placeholder="+1 (555) 000-0000" />
</div>
<div>
<label className="mb-1 block text-sm font-medium">Date</label>
<Input type="date" />
</div>
<div>
<label className="mb-1 block text-sm font-medium">Time</label>
<Input type="time" />
</div>
</div>
)
}
// States
export const Disabled: Story = {
args: {
disabled: true,
placeholder: 'Disabled input',
defaultValue: 'Cannot edit this'
}
}
export const ReadOnly: Story = {
args: {
readOnly: true,
defaultValue: 'Read-only value'
}
}
export const ErrorState: Story = {
render: () => (
<div className="w-80">
<Input placeholder="Invalid input..." aria-invalid />
</div>
)
}
// All States
export const AllStates: Story = {
render: () => (
<div className="flex w-80 flex-col gap-4">
<div>
<p className="mb-2 text-sm text-muted-foreground">Normal</p>
<Input placeholder="Normal input" />
</div>
<div>
<p className="mb-2 text-sm text-muted-foreground">With Value</p>
<Input defaultValue="Input with value" />
</div>
<div>
<p className="mb-2 text-sm text-muted-foreground">Disabled</p>
<Input disabled placeholder="Disabled input" />
</div>
<div>
<p className="mb-2 text-sm text-muted-foreground">Read-only</p>
<Input readOnly defaultValue="Read-only value" />
</div>
<div>
<p className="mb-2 text-sm text-muted-foreground">Error State</p>
<Input placeholder="Invalid input" aria-invalid />
</div>
</div>
)
}
// Controlled
export const Controlled: Story = {
render: function ControlledExample() {
const [value, setValue] = useState('')
return (
<div className="flex w-80 flex-col gap-4">
<Input placeholder="Type something..." value={value} onChange={(e) => setValue(e.target.value)} />
<div className="text-sm text-muted-foreground">
Current value: <span className="font-mono">{value || '(empty)'}</span>
</div>
<div className="text-sm text-muted-foreground">Length: {value.length}</div>
</div>
)
}
}
// With Labels
export const WithLabels: Story = {
render: () => (
<div className="flex w-80 flex-col gap-4">
<div>
<label htmlFor="username" className="mb-1 block text-sm font-medium">
Username
</label>
<Input id="username" placeholder="Enter username..." />
</div>
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium">
Email
</label>
<Input id="email" type="email" placeholder="email@example.com" />
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm font-medium">
Password
</label>
<Input id="password" type="password" placeholder="Enter password..." />
</div>
</div>
)
}
// With Helper Text
export const WithHelperText: Story = {
render: () => (
<div className="flex w-80 flex-col gap-4">
<div>
<label htmlFor="email-helper" className="mb-1 block text-sm font-medium">
Email
</label>
<Input id="email-helper" type="email" placeholder="email@example.com" />
<p className="mt-1 text-xs text-muted-foreground">We'll never share your email with anyone else.</p>
</div>
<div>
<label htmlFor="password-helper" className="mb-1 block text-sm font-medium">
Password
</label>
<Input id="password-helper" type="password" placeholder="Enter password..." />
<p className="mt-1 text-xs text-muted-foreground">Must be at least 8 characters long.</p>
</div>
</div>
)
}
// With Error Message
export const WithErrorMessage: Story = {
render: () => (
<div className="flex w-80 flex-col gap-4">
<div>
<label htmlFor="email-error" className="mb-1 block text-sm font-medium">
Email
</label>
<Input id="email-error" type="email" placeholder="email@example.com" aria-invalid />
<p className="mt-1 text-xs text-destructive">Please enter a valid email address.</p>
</div>
<div>
<label htmlFor="password-error" className="mb-1 block text-sm font-medium">
Password
</label>
<Input id="password-error" type="password" placeholder="Enter password..." aria-invalid />
<p className="mt-1 text-xs text-destructive">Password must be at least 8 characters.</p>
</div>
</div>
)
}
// Validation States
export const ValidationStates: Story = {
render: () => (
<div className="flex w-80 flex-col gap-6">
<div>
<p className="mb-2 text-sm font-medium">Valid Input</p>
<Input type="email" placeholder="email@example.com" defaultValue="user@example.com" />
<p className="mt-1 text-xs text-green-600"> Email is valid</p>
</div>
<div>
<p className="mb-2 text-sm font-medium">Invalid Email Format</p>
<Input type="email" placeholder="email@example.com" defaultValue="invalid-email" aria-invalid />
<p className="mt-1 text-xs text-destructive"> Please enter a valid email address</p>
</div>
<div>
<p className="mb-2 text-sm font-medium">Required Field Empty</p>
<Input placeholder="Required field" aria-invalid aria-required />
<p className="mt-1 text-xs text-destructive"> This field is required</p>
</div>
<div>
<p className="mb-2 text-sm font-medium">Password Too Short</p>
<Input type="password" placeholder="Enter password..." defaultValue="123" aria-invalid />
<p className="mt-1 text-xs text-destructive"> Password must be at least 8 characters</p>
</div>
<div>
<p className="mb-2 text-sm font-medium">Number Out of Range</p>
<Input type="number" placeholder="1-100" defaultValue="150" min="1" max="100" aria-invalid />
<p className="mt-1 text-xs text-destructive"> Value must be between 1 and 100</p>
</div>
</div>
)
}
// Real-time Validation
export const RealTimeValidation: Story = {
render: function RealTimeValidationExample() {
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState('')
const validateEmail = (value: string) => {
if (!value) {
setEmailError('Email is required')
return false
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
setEmailError('Please enter a valid email address')
return false
}
setEmailError('')
return true
}
return (
<div className="w-80 space-y-4">
<h3 className="text-base font-semibold">Real-time Email Validation</h3>
<div>
<label htmlFor="realtime-email" className="mb-1 block text-sm font-medium">
Email Address
</label>
<Input
id="realtime-email"
type="email"
placeholder="email@example.com"
value={email}
onChange={(e) => {
setEmail(e.target.value)
validateEmail(e.target.value)
}}
aria-invalid={!!emailError}
/>
{emailError ? (
<p className="mt-1 text-xs text-destructive">{emailError}</p>
) : email ? (
<p className="mt-1 text-xs text-green-600"> Email is valid</p>
) : (
<p className="mt-1 text-xs text-muted-foreground">Enter your email address</p>
)}
</div>
</div>
)
}
}
// File Input
export const FileInput: Story = {
render: () => (
<div className="w-80">
<label htmlFor="file" className="mb-1 block text-sm font-medium">
Upload File
</label>
<Input id="file" type="file" />
</div>
)
}
// Multiple Files
export const MultipleFiles: Story = {
render: () => (
<div className="w-80">
<label htmlFor="files" className="mb-1 block text-sm font-medium">
Upload Multiple Files
</label>
<Input id="files" type="file" multiple />
</div>
)
}
// Form Example
export const FormExample: Story = {
render: function FormExample() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitted, setSubmitted] = useState(false)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const newErrors: Record<string, string> = {}
if (!formData.username) newErrors.username = 'Username is required'
if (!formData.email) newErrors.email = 'Email is required'
if (!formData.password) newErrors.password = 'Password is required'
if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'Passwords do not match'
setErrors(newErrors)
if (Object.keys(newErrors).length === 0) {
setSubmitted(true)
setTimeout(() => setSubmitted(false), 3000)
}
}
return (
<form onSubmit={handleSubmit} className="w-80 space-y-4">
<h3 className="text-base font-semibold">Sign Up Form</h3>
<div>
<label htmlFor="form-username" className="mb-1 block text-sm font-medium">
Username
</label>
<Input
id="form-username"
placeholder="Enter username..."
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
aria-invalid={!!errors.username}
/>
{errors.username && <p className="mt-1 text-xs text-destructive">{errors.username}</p>}
</div>
<div>
<label htmlFor="form-email" className="mb-1 block text-sm font-medium">
Email
</label>
<Input
id="form-email"
type="email"
placeholder="email@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
aria-invalid={!!errors.email}
/>
{errors.email && <p className="mt-1 text-xs text-destructive">{errors.email}</p>}
</div>
<div>
<label htmlFor="form-password" className="mb-1 block text-sm font-medium">
Password
</label>
<Input
id="form-password"
type="password"
placeholder="Enter password..."
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
aria-invalid={!!errors.password}
/>
{errors.password && <p className="mt-1 text-xs text-destructive">{errors.password}</p>}
</div>
<div>
<label htmlFor="form-confirm" className="mb-1 block text-sm font-medium">
Confirm Password
</label>
<Input
id="form-confirm"
type="password"
placeholder="Confirm password..."
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
aria-invalid={!!errors.confirmPassword}
/>
{errors.confirmPassword && <p className="mt-1 text-xs text-destructive">{errors.confirmPassword}</p>}
</div>
<button
type="submit"
className="w-full rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90">
Sign Up
</button>
{submitted && <p className="text-center text-sm text-green-600">Form submitted successfully!</p>}
</form>
)
}
}
// Search Example
export const SearchExample: Story = {
render: function SearchExample() {
const [query, setQuery] = useState('')
const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape']
const filtered = items.filter((item) => item.toLowerCase().includes(query.toLowerCase()))
return (
<div className="w-80 space-y-4">
<div>
<label htmlFor="search" className="mb-1 flex items-center gap-2 text-sm font-medium">
<Search className="size-4" />
Search Fruits
</label>
<Input
id="search"
type="search"
placeholder="Type to search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="rounded-md border p-3">
<p className="mb-2 text-sm font-medium">Results ({filtered.length})</p>
{filtered.length > 0 ? (
<ul className="space-y-1">
{filtered.map((item) => (
<li key={item} className="text-sm text-muted-foreground">
{item}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">No results found</p>
)}
</div>
</div>
)
}
}
// Real World Examples
export const RealWorldExamples: Story = {
render: () => (
<div className="flex flex-col gap-8">
{/* Login Form */}
<div className="w-80">
<h3 className="mb-4 text-base font-semibold">Login Form</h3>
<div className="space-y-3">
<div>
<label htmlFor="login-email" className="mb-1 flex items-center gap-2 text-sm font-medium">
<Mail className="size-4" />
Email
</label>
<Input id="login-email" type="email" placeholder="email@example.com" />
</div>
<div>
<label htmlFor="login-password" className="mb-1 block text-sm font-medium">
Password
</label>
<Input id="login-password" type="password" placeholder="Enter password..." />
</div>
<button
type="button"
className="w-full rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90">
Sign In
</button>
</div>
</div>
{/* Profile Form */}
<div className="w-80">
<h3 className="mb-4 text-base font-semibold">Profile Information</h3>
<div className="space-y-3">
<div>
<label htmlFor="profile-name" className="mb-1 flex items-center gap-2 text-sm font-medium">
<User className="size-4" />
Full Name
</label>
<Input id="profile-name" placeholder="John Doe" />
</div>
<div>
<label htmlFor="profile-email" className="mb-1 flex items-center gap-2 text-sm font-medium">
<Mail className="size-4" />
Email
</label>
<Input id="profile-email" type="email" placeholder="john@example.com" />
</div>
<div>
<label htmlFor="profile-phone" className="mb-1 block text-sm font-medium">
Phone
</label>
<Input id="profile-phone" type="tel" placeholder="+1 (555) 000-0000" />
</div>
<button
type="button"
className="w-full rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90">
Save Changes
</button>
</div>
</div>
</div>
)
}
// Accessibility
export const Accessibility: Story = {
render: () => (
<div className="w-80 space-y-6">
<div>
<h3 className="mb-4 text-base font-semibold">Keyboard Navigation</h3>
<p className="mb-4 text-sm text-muted-foreground">Use Tab to navigate between inputs.</p>
<div className="space-y-3">
<Input placeholder="First input" />
<Input placeholder="Second input" />
<Input placeholder="Third input" />
</div>
</div>
<div>
<h3 className="mb-4 text-base font-semibold">ARIA Labels</h3>
<p className="mb-4 text-sm text-muted-foreground">
Inputs include proper ARIA attributes for screen reader support.
</p>
<div className="space-y-3">
<Input placeholder="Input with aria-label" aria-label="Username input" />
<Input placeholder="Invalid input" aria-invalid aria-describedby="error-message" />
<p id="error-message" className="text-xs text-destructive">
This input has an error
</p>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff