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 ( +