mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-17 16:14:44 +08:00
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:
parent
4a38fd6ebc
commit
b75c10d9f9
4
packages/ui/src/components/composites/Input/index.ts
Normal file
4
packages/ui/src/components/composites/Input/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import type { CompositeInputProps, SelectGroup, SelectItem } from './input'
|
||||
import { CompositeInput } from './input'
|
||||
|
||||
export { CompositeInput, type CompositeInputProps, type SelectGroup, type SelectItem }
|
||||
371
packages/ui/src/components/composites/Input/input.tsx
Normal file
371
packages/ui/src/components/composites/Input/input.tsx
Normal 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 }
|
||||
@ -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'
|
||||
|
||||
147
packages/ui/src/components/primitives/input-group.tsx
Normal file
147
packages/ui/src/components/primitives/input-group.tsx
Normal 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 }
|
||||
23
packages/ui/src/components/primitives/input.tsx
Normal file
23
packages/ui/src/components/primitives/input.tsx
Normal 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 }
|
||||
17
packages/ui/src/components/primitives/textarea.tsx
Normal file
17
packages/ui/src/components/primitives/textarea.tsx
Normal 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 }
|
||||
@ -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
|
||||
}
|
||||
|
||||
1530
packages/ui/stories/components/composites/CompositeInput.stories.tsx
Normal file
1530
packages/ui/stories/components/composites/CompositeInput.stories.tsx
Normal file
File diff suppressed because it is too large
Load Diff
629
packages/ui/stories/components/primitives/Input.stories.tsx
Normal file
629
packages/ui/stories/components/primitives/Input.stories.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1083
packages/ui/stories/components/primitives/InputGroup.stories.tsx
Normal file
1083
packages/ui/stories/components/primitives/InputGroup.stories.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user