This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import Input, { inputVariants } from './index'
// Mock the i18n hook
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'common.operation.search': 'Search',
'common.placeholder.input': 'Please input',
}
return translations[key] || ''
},
}),
}))
describe('Input component', () => {
describe('Variants', () => {
it('should return correct classes for regular size', () => {
const result = inputVariants({ size: 'regular' })
expect(result).toContain('px-3')
expect(result).toContain('radius-md')
expect(result).toContain('system-sm-regular')
})
it('should return correct classes for large size', () => {
const result = inputVariants({ size: 'large' })
expect(result).toContain('px-4')
expect(result).toContain('radius-lg')
expect(result).toContain('system-md-regular')
})
it('should use regular size as default', () => {
const result = inputVariants({})
expect(result).toContain('px-3')
expect(result).toContain('radius-md')
expect(result).toContain('system-sm-regular')
})
})
it('renders correctly with default props', () => {
render(<Input />)
const input = screen.getByPlaceholderText('Please input')
expect(input).toBeInTheDocument()
expect(input).not.toBeDisabled()
expect(input).not.toHaveClass('cursor-not-allowed')
})
it('shows left icon when showLeftIcon is true', () => {
render(<Input showLeftIcon />)
const searchIcon = document.querySelector('svg')
expect(searchIcon).toBeInTheDocument()
const input = screen.getByPlaceholderText('Search')
expect(input).toHaveClass('pl-[26px]')
})
it('shows clear icon when showClearIcon is true and has value', () => {
render(<Input showClearIcon value="test" />)
const clearIcon = document.querySelector('.group svg')
expect(clearIcon).toBeInTheDocument()
const input = screen.getByDisplayValue('test')
expect(input).toHaveClass('pr-[26px]')
})
it('does not show clear icon when disabled, even with value', () => {
render(<Input showClearIcon value="test" disabled />)
const clearIcon = document.querySelector('.group svg')
expect(clearIcon).not.toBeInTheDocument()
})
it('calls onClear when clear icon is clicked', () => {
const onClear = jest.fn()
render(<Input showClearIcon value="test" onClear={onClear} />)
const clearIconContainer = document.querySelector('.group')
fireEvent.click(clearIconContainer!)
expect(onClear).toHaveBeenCalledTimes(1)
})
it('shows warning icon when destructive is true', () => {
render(<Input destructive />)
const warningIcon = document.querySelector('svg')
expect(warningIcon).toBeInTheDocument()
const input = screen.getByPlaceholderText('Please input')
expect(input).toHaveClass('border-components-input-border-destructive')
})
it('applies disabled styles when disabled', () => {
render(<Input disabled />)
const input = screen.getByPlaceholderText('Please input')
expect(input).toBeDisabled()
expect(input).toHaveClass('cursor-not-allowed')
expect(input).toHaveClass('bg-components-input-bg-disabled')
})
it('displays custom unit when provided', () => {
render(<Input unit="km" />)
const unitElement = screen.getByText('km')
expect(unitElement).toBeInTheDocument()
})
it('applies custom className and style', () => {
const customClass = 'test-class'
const customStyle = { color: 'red' }
render(<Input className={customClass} styleCss={customStyle} />)
const input = screen.getByPlaceholderText('Please input')
expect(input).toHaveClass(customClass)
expect(input).toHaveStyle('color: red')
})
it('applies large size variant correctly', () => {
render(<Input size={'large' as any} />)
const input = screen.getByPlaceholderText('Please input')
expect(input.className).toContain(inputVariants({ size: 'large' }))
})
it('uses custom placeholder when provided', () => {
const placeholder = 'Custom placeholder'
render(<Input placeholder={placeholder} />)
const input = screen.getByPlaceholderText(placeholder)
expect(input).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,424 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Input from '.'
const meta = {
title: 'Base/Data Entry/Input',
component: Input,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['regular', 'large'],
description: 'Input size',
},
type: {
control: 'select',
options: ['text', 'number', 'email', 'password', 'url', 'tel'],
description: 'Input type',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
destructive: {
control: 'boolean',
description: 'Error/destructive state',
},
showLeftIcon: {
control: 'boolean',
description: 'Show search icon on left',
},
showClearIcon: {
control: 'boolean',
description: 'Show clear button when input has value',
},
unit: {
control: 'text',
description: 'Unit text displayed on right (e.g., "px", "ms")',
},
},
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const InputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '400px' }}>
<Input
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Input changed:', e.target.value)
}}
onClear={() => {
setValue('')
console.log('Input cleared')
}}
/>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
type: 'text',
},
}
// Large size
export const LargeSize: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'large',
placeholder: 'Enter text...',
type: 'text',
},
}
// With search icon
export const WithSearchIcon: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showLeftIcon: true,
placeholder: 'Search...',
type: 'text',
},
}
// With clear button
export const WithClearButton: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showClearIcon: true,
value: 'Some text to clear',
placeholder: 'Type something...',
type: 'text',
},
}
// Search input (icon + clear)
export const SearchInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showLeftIcon: true,
showClearIcon: true,
value: '',
placeholder: 'Search...',
type: 'text',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
value: 'Disabled input',
disabled: true,
type: 'text',
},
}
// Destructive/error state
export const DestructiveState: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
value: 'invalid@email',
destructive: true,
placeholder: 'Enter email...',
type: 'email',
},
}
// Number input
export const NumberInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'number',
placeholder: 'Enter a number...',
value: '0',
},
}
// With unit
export const WithUnit: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'number',
value: '100',
unit: 'px',
placeholder: 'Enter value...',
},
}
// Email input
export const EmailInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'email',
placeholder: 'Enter your email...',
showClearIcon: true,
},
}
// Password input
export const PasswordInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'password',
placeholder: 'Enter password...',
value: 'secret123',
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [regularValue, setRegularValue] = useState('')
const [largeValue, setLargeValue] = useState('')
return (
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Regular Size</label>
<Input
size="regular"
value={regularValue}
onChange={e => setRegularValue(e.target.value)}
placeholder="Regular input..."
showClearIcon
onClear={() => setRegularValue('')}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Large Size</label>
<Input
size="large"
value={largeValue}
onChange={e => setLargeValue(e.target.value)}
placeholder="Large input..."
showClearIcon
onClear={() => setLargeValue('')}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
// State comparison
const StateComparisonDemo = () => {
const [normalValue, setNormalValue] = useState('Normal state')
const [errorValue, setErrorValue] = useState('Error state')
return (
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Normal</label>
<Input
value={normalValue}
onChange={e => setNormalValue(e.target.value)}
showClearIcon
onClear={() => setNormalValue('')}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Destructive</label>
<Input
value={errorValue}
onChange={e => setErrorValue(e.target.value)}
destructive
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Disabled</label>
<Input
value="Disabled input"
onChange={() => undefined}
disabled
/>
</div>
</div>
)
}
export const StateComparison: Story = {
render: () => <StateComparisonDemo />,
}
// Form example
const FormExampleDemo = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
age: '',
website: '',
})
const [errors, setErrors] = useState({
email: false,
age: false,
})
const validateEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">User Profile</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Name</label>
<Input
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter your name..."
showClearIcon
onClear={() => setFormData({ ...formData, name: '' })}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Email</label>
<Input
type="email"
value={formData.email}
onChange={(e) => {
setFormData({ ...formData, email: e.target.value })
setErrors({ ...errors, email: e.target.value ? !validateEmail(e.target.value) : false })
}}
placeholder="Enter your email..."
destructive={errors.email}
showClearIcon
onClear={() => {
setFormData({ ...formData, email: '' })
setErrors({ ...errors, email: false })
}}
/>
{errors.email && (
<span className="text-xs text-red-600">Please enter a valid email address</span>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Age</label>
<Input
type="number"
value={formData.age}
onChange={(e) => {
setFormData({ ...formData, age: e.target.value })
setErrors({ ...errors, age: e.target.value ? Number(e.target.value) < 18 : false })
}}
placeholder="Enter your age..."
destructive={errors.age}
unit="years"
/>
{errors.age && (
<span className="text-xs text-red-600">Must be 18 or older</span>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Website</label>
<Input
type="url"
value={formData.website}
onChange={e => setFormData({ ...formData, website: e.target.value })}
placeholder="https://example.com"
showClearIcon
onClear={() => setFormData({ ...formData, website: '' })}
/>
</div>
</div>
</div>
)
}
export const FormExample: Story = {
render: () => <FormExampleDemo />,
}
// Search example
const SearchExampleDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape']
const filteredItems = items.filter(item =>
item.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '400px' }} className="flex flex-col gap-4">
<Input
size="large"
showLeftIcon
showClearIcon
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery('')}
placeholder="Search fruits..."
/>
{searchQuery && (
<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs text-gray-500">
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
</div>
<div className="flex flex-col gap-1">
{filteredItems.map(item => (
<div key={item} className="text-sm text-gray-700">
{item}
</div>
))}
</div>
</div>
)}
</div>
)
}
export const SearchExample: Story = {
render: () => <SearchExampleDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'text',
placeholder: 'Type something...',
disabled: false,
destructive: false,
showLeftIcon: false,
showClearIcon: true,
unit: '',
},
}

View File

@@ -0,0 +1,140 @@
import cn from '@/utils/classnames'
import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react'
import { type VariantProps, cva } from 'class-variance-authority'
import { noop } from 'lodash-es'
import type { CSSProperties, ChangeEventHandler, FocusEventHandler } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { CopyFeedbackNew } from '../copy-feedback'
export const inputVariants = cva(
'',
{
variants: {
size: {
regular: 'px-3 radius-md system-sm-regular',
large: 'px-4 radius-lg system-md-regular',
},
},
defaultVariants: {
size: 'regular',
},
},
)
export type InputProps = {
showLeftIcon?: boolean
showClearIcon?: boolean
showCopyIcon?: boolean
onClear?: () => void
disabled?: boolean
destructive?: boolean
wrapperClassName?: string
styleCss?: CSSProperties
unit?: string
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants>
const removeLeadingZeros = (value: string) => value.replace(/^(-?)0+(?=\d)/, '$1')
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
size,
disabled,
destructive,
showLeftIcon,
showClearIcon,
showCopyIcon,
onClear,
wrapperClassName,
className,
styleCss,
value,
placeholder,
onChange = noop,
onBlur = noop,
unit,
...props
}, ref) => {
const { t } = useTranslation()
const handleNumberChange: ChangeEventHandler<HTMLInputElement> = (e) => {
if (value === 0) {
// remove leading zeros
const formattedValue = removeLeadingZeros(e.target.value)
if (e.target.value !== formattedValue)
e.target.value = formattedValue
}
onChange(e)
}
const handleNumberBlur: FocusEventHandler<HTMLInputElement> = (e) => {
// remove leading zeros
const formattedValue = removeLeadingZeros(e.target.value)
if (e.target.value !== formattedValue) {
e.target.value = formattedValue
onChange({
...e,
type: 'change',
target: {
...e.target,
value: formattedValue,
},
})
}
onBlur(e)
}
return (
<div className={cn('relative w-full', wrapperClassName)}>
{showLeftIcon && <RiSearchLine className={cn('absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-components-input-text-placeholder')} />}
<input
ref={ref}
style={styleCss}
className={cn(
'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
inputVariants({ size }),
showLeftIcon && 'pl-[26px]',
showLeftIcon && size === 'large' && 'pl-7',
showClearIcon && value && 'pr-[26px]',
showClearIcon && value && size === 'large' && 'pr-7',
(destructive || showCopyIcon) && 'pr-[26px]',
(destructive || showCopyIcon) && size === 'large' && 'pr-7',
disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
className,
)}
placeholder={placeholder ?? (showLeftIcon
? (t('common.operation.search') || '')
: (t('common.placeholder.input') || ''))}
value={value}
onChange={props.type === 'number' ? handleNumberChange : onChange}
onBlur={props.type === 'number' ? handleNumberBlur : onBlur}
disabled={disabled}
{...props}
/>
{showClearIcon && value && !disabled && !destructive && (
<div className={cn('group absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-[1px]')} onClick={onClear}>
<RiCloseCircleFill className='h-3.5 w-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary' />
</div>
)}
{destructive && (
<RiErrorWarningLine className='absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-text-destructive-secondary' />
)}
{showCopyIcon && (
<div className={cn('group absolute right-0 top-1/2 -translate-y-1/2 cursor-pointer')}>
<CopyFeedbackNew
content={String(value ?? '')}
className='!h-7 !w-7 hover:bg-transparent'
/>
</div>
)}
{
unit && (
<div className='system-sm-regular absolute right-2 top-1/2 -translate-y-1/2 text-text-tertiary'>
{unit}
</div>
)
}
</div>
)
})
Input.displayName = 'Input'
export default Input