From b75c10d9f9a2a25cf7b8f6b07d9eaeac36980d67 Mon Sep 17 00:00:00 2001 From: Phantom Date: Mon, 1 Dec 2025 17:15:07 +0800 Subject: [PATCH] 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 f7f689b326f4955ffe6cee677d7b932e2783ce14. * 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 Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> --- .../src/components/composites/Input/index.ts | 4 + .../src/components/composites/Input/input.tsx | 371 ++++ packages/ui/src/components/index.ts | 9 + .../src/components/primitives/input-group.tsx | 147 ++ .../ui/src/components/primitives/input.tsx | 23 + .../ui/src/components/primitives/textarea.tsx | 17 + packages/ui/src/utils/index.ts | 22 + .../composites/CompositeInput.stories.tsx | 1530 +++++++++++++++++ .../components/primitives/Input.stories.tsx | 629 +++++++ .../primitives/InputGroup.stories.tsx | 1083 ++++++++++++ 10 files changed, 3835 insertions(+) create mode 100644 packages/ui/src/components/composites/Input/index.ts create mode 100644 packages/ui/src/components/composites/Input/input.tsx create mode 100644 packages/ui/src/components/primitives/input-group.tsx create mode 100644 packages/ui/src/components/primitives/input.tsx create mode 100644 packages/ui/src/components/primitives/textarea.tsx create mode 100644 packages/ui/stories/components/composites/CompositeInput.stories.tsx create mode 100644 packages/ui/stories/components/primitives/Input.stories.tsx create mode 100644 packages/ui/stories/components/primitives/InputGroup.stories.tsx diff --git a/packages/ui/src/components/composites/Input/index.ts b/packages/ui/src/components/composites/Input/index.ts new file mode 100644 index 000000000..3d5f36e60 --- /dev/null +++ b/packages/ui/src/components/composites/Input/index.ts @@ -0,0 +1,4 @@ +import type { CompositeInputProps, SelectGroup, SelectItem } from './input' +import { CompositeInput } from './input' + +export { CompositeInput, type CompositeInputProps, type SelectGroup, type SelectItem } diff --git a/packages/ui/src/components/composites/Input/input.tsx b/packages/ui/src/components/composites/Input/input.tsx new file mode 100644 index 000000000..80c83fe15 --- /dev/null +++ b/packages/ui/src/components/composites/Input/input.tsx @@ -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> + size: VariantProps['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 ( + + {type === 'text' && } + {type === 'password' && } + + ) +} + +interface SelectItem { + label: ReactNode + value: string +} + +interface SelectGroup { + label: ReactNode + items: SelectItem[] +} + +interface CompositeInputProps + extends Omit, + VariantProps { + buttonProps?: { + label?: ReactNode + onClick: React.DOMAttributes['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 ( + +
{buttonProps.label}
+
+ ) + } + }, [buttonProps, disabled, size]) + + const emailContent = useMemo(() => { + if (!prefix) { + console.warn('CompositeInput: "email" variant requires a "prefix" prop to be provided.') + return null + } else { + return
{prefix}
+ } + }, [disabled, prefix, size]) + + const selectContent = useMemo(() => { + if (!selectProps) { + console.warn('CompositeInput: "select" variant requires a "selectProps" prop to be provided.') + return null + } else { + return ( +
+ +
+ ) + } + }, [disabled, selectProps, size]) + + return ( + + {variant === 'email' && emailContent} + {variant === 'select' && selectContent} +
+ + {(variant === 'default' || variant === 'button') && ( + <> + + + + + + + + )} +
+ {variant === 'button' && buttonContent} +
+ ) +} + +export { CompositeInput, type CompositeInputProps, type SelectGroup, type SelectItem } diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 30777112f..4545e19ce 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -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' diff --git a/packages/ui/src/components/primitives/input-group.tsx b/packages/ui/src/components/primitives/input-group.tsx new file mode 100644 index 000000000..8c9ff10fa --- /dev/null +++ b/packages/ui/src/components/primitives/input-group.tsx @@ -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 ( +
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) { + return ( +
{ + 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, 'size'> & VariantProps) { + return ( +