feat(textarea): add Textarea component with variants and Storybook examples (#11260)

* feat(textarea): add Textarea component with variants and Storybook examples

* feat(textarea): enhance Textarea component with context, improved variants, and Storybook examples

* Fine-tuning the style

* fix ci

* feat(textarea): refactor Textarea stories to use custom label and caption components

* feat(textarea): add TextareaContext for managing textarea state

* fix: format

* feat(textarea): refactor TextareaInput to simplify props and remove autoSize handling

* feat(textarea): remove TextareaContext and update stories to reflect new error handling

* refactor(textarea): remove TextareaRoot component

After removing TextareaContext, TextareaRoot became a simple wrapper div
with no functionality beyond applying layout styles. This change:

- Removes TextareaRoot component and its exports
- Updates all Storybook stories to use plain divs with the same styling
- Simplifies the component API while maintaining the same functionality

Addresses reviewer feedback: https://github.com/CherryHQ/cherry-studio/pull/11260#discussion_r2580009134

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: format

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: MyPrototypeWhat <daoquqiexing@gmail.com>
This commit is contained in:
SuYao 2025-12-02 18:33:06 +08:00 committed by GitHub
parent 1a6263cf7f
commit ebddfd3e56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 530 additions and 12 deletions

View File

@ -74,4 +74,4 @@ export * from './primitives/radioGroup'
export * from './primitives/select'
export * from './primitives/shadcn-io/dropzone'
export * from './primitives/tabs'
export * from './primitives/textarea'
export * as Textarea from './primitives/textarea'

View File

@ -1,7 +1,8 @@
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 type { TextareaInputProps } from '@cherrystudio/ui/components/primitives/textarea'
import * as 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'
@ -131,9 +132,9 @@ function InputGroupInput({ className, ...props }: InputProps) {
)
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
function InputGroupTextarea({ className, ...props }: TextareaInputProps) {
return (
<Textarea
<Textarea.Input
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',

View File

@ -1,17 +1,116 @@
import { cn } from '@cherrystudio/ui/utils'
import { cn } from '@cherrystudio/ui/utils/index'
import { composeEventHandlers } from '@radix-ui/primitive'
import { useCallbackRef } from '@radix-ui/react-use-callback-ref'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import { cva } from 'class-variance-authority'
import * as React from 'react'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
/* -------------------------------------------------------------------------------------------------
* Variants
* -----------------------------------------------------------------------------------------------*/
const textareaVariants = cva(
cn(
'flex field-sizing-content min-h-16 w-full border bg-transparent px-4 py-3 text-lg shadow-xs transition-[color,box-shadow] outline-none resize-y',
'rounded-xs',
'border-input text-foreground placeholder:text-foreground-secondary',
'focus-visible:border-primary focus-visible:ring-ring focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
'md:text-sm'
),
{
variants: {
hasError: {
true: 'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
false: ''
}
},
defaultVariants: {
hasError: false
}
}
)
/* -------------------------------------------------------------------------------------------------
* TextareaInput
* -----------------------------------------------------------------------------------------------*/
const INPUT_NAME = 'TextareaInput'
interface TextareaInputProps extends Omit<React.ComponentPropsWithoutRef<'textarea'>, 'value' | 'defaultValue'> {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
hasError?: boolean
ref?: React.Ref<HTMLTextAreaElement>
}
function TextareaInput({
value: valueProp,
defaultValue,
onValueChange,
hasError = false,
className,
ref,
...props
}: TextareaInputProps) {
const [value = '', setValue] = useControllableState({
prop: valueProp,
defaultProp: defaultValue ?? '',
onChange: onValueChange
})
const handleChange = useCallbackRef((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = event.target.value
if (props.maxLength && newValue.length > props.maxLength) {
return
}
setValue(newValue)
})
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
)}
data-slot="textarea-input"
{...props}
ref={ref}
value={value}
onChange={composeEventHandlers(props.onChange, handleChange)}
aria-invalid={hasError}
className={cn(textareaVariants({ hasError }), className)}
/>
)
}
export { Textarea }
TextareaInput.displayName = INPUT_NAME
/* -------------------------------------------------------------------------------------------------
* TextareaCharCount
* -----------------------------------------------------------------------------------------------*/
const CHAR_COUNT_NAME = 'TextareaCharCount'
interface TextareaCharCountProps extends React.ComponentPropsWithoutRef<'div'> {
value?: string
maxLength?: number
}
function TextareaCharCount({ value = '', maxLength, className, ...props }: TextareaCharCountProps) {
return (
<div
data-slot="textarea-char-count"
{...props}
className={cn('absolute bottom-2 right-2 text-xs text-muted-foreground', className)}>
{value.length}/{maxLength}
</div>
)
}
TextareaCharCount.displayName = CHAR_COUNT_NAME
/* ---------------------------------------------------------------------------------------------- */
const Input = TextareaInput
const CharCount = TextareaCharCount
export { CharCount, Input }
export type { TextareaCharCountProps, TextareaInputProps }

View File

@ -0,0 +1,418 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import * as Textarea from '../../../src/components/primitives/textarea'
const meta: Meta<typeof Textarea.Input> = {
title: 'Components/Primitives/Textarea',
component: Textarea.Input,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A composable multi-line text input built with Radix primitives. Supports controlled/uncontrolled modes, auto-resize (via field-sizing-content), character counting, and error states.'
}
}
},
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof meta>
// Basic Usage
export const Basic: Story = {
render: () => (
<div className="flex w-full flex-col gap-2 w-[400px]">
<Textarea.Input placeholder="Type your message here..." />
</div>
)
}
// With Label
export const WithLabel: Story = {
render: () => (
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Description</div>
<Textarea.Input placeholder="Tell us about yourself..." />
</div>
)
}
// Required Field
export const RequiredField: Story = {
render: () => (
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">
<span className="text-destructive mr-1">*</span>Bio
</div>
<Textarea.Input placeholder="This field is required..." />
</div>
)
}
// With Caption
export const WithCaption: Story = {
render: () => (
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Comments</div>
<Textarea.Input placeholder="Enter your comments..." />
<div className="text-sm flex items-center gap-1.5 leading-4 text-foreground-muted">
Please provide detailed feedback
</div>
</div>
)
}
// Error State
export const ErrorState: Story = {
render: () => (
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Message</div>
<Textarea.Input placeholder="Enter your message..." hasError />
<div className="text-sm flex items-center gap-1.5 leading-4 text-destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
<span>This field cannot be empty</span>
</div>
</div>
)
}
// With Character Count
export const WithCharacterCount: Story = {
render: function WithCharacterCountExample() {
const [value, setValue] = useState('')
return (
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Tweet</div>
<div className="relative">
<Textarea.Input value={value} onValueChange={setValue} maxLength={280} placeholder="What's happening?" />
<Textarea.CharCount value={value} maxLength={280} />
</div>
<div className="text-sm flex items-center gap-1.5 leading-4 text-foreground-muted">Maximum 280 characters</div>
</div>
)
}
}
// Auto Resize (built-in via field-sizing-content)
export const AutoResize: Story = {
render: function AutoResizeExample() {
const [value, setValue] = useState('')
return (
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Auto-resizing Textarea</div>
<Textarea.Input value={value} onValueChange={setValue} placeholder="This textarea grows with your content..." />
<div className="text-sm flex items-center gap-1.5 leading-4 text-foreground-muted">
Try typing multiple lines
</div>
</div>
)
}
}
// Disabled State
export const Disabled: Story = {
render: () => (
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px] cursor-not-allowed opacity-70">Disabled Field</div>
<Textarea.Input defaultValue="This textarea is disabled" disabled />
</div>
)
}
// Controlled
export const Controlled: Story = {
render: function ControlledExample() {
const [value, setValue] = useState('')
return (
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Controlled Textarea</div>
<Textarea.Input value={value} onValueChange={setValue} placeholder="Type something..." />
</div>
<div className="w-[400px] text-sm text-muted-foreground">
<div className="rounded-md border border-border bg-muted p-3">
<div className="mb-1 font-medium">Current value:</div>
<pre className="text-xs">{value || '(empty)'}</pre>
<div className="mt-2 text-xs">Characters: {value.length}</div>
</div>
</div>
</div>
)
}
}
// All States
export const AllStates: Story = {
render: function AllStatesExample() {
const [value1, setValue1] = useState('')
const [value2, setValue2] = useState('This textarea has some content')
const [value4, setValue4] = useState('')
return (
<div className="flex flex-col gap-6">
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Default State</p>
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Default</div>
<Textarea.Input value={value1} onValueChange={setValue1} placeholder="Enter text..." />
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Filled State</p>
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Filled</div>
<Textarea.Input value={value2} onValueChange={setValue2} />
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Disabled State</p>
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px] cursor-not-allowed opacity-70">Disabled</div>
<Textarea.Input defaultValue="Disabled textarea with content" disabled />
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Error State</p>
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Error</div>
<Textarea.Input value={value4} onValueChange={setValue4} hasError />
<div className="text-sm flex items-center gap-1.5 leading-4 text-destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
<span>This field is required</span>
</div>
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-muted-foreground">Focus State (click to focus)</p>
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Focus</div>
<Textarea.Input placeholder="Click to see focus state" />
</div>
</div>
</div>
)
}
}
// Real World Examples
export const RealWorldExamples: Story = {
render: function RealWorldExample() {
const [tweet, setTweet] = useState('')
const [feedback, setFeedback] = useState('')
const [message, setMessage] = useState('')
const tweetError = tweet.length > 280 ? 'Tweet is too long' : undefined
const messageError =
message.length > 0 && message.length < 10 ? 'Message must be at least 10 characters' : undefined
return (
<div className="flex flex-col gap-8">
{/* Tweet Composer */}
<div>
<h3 className="mb-3 text-sm font-semibold">Tweet Composer</h3>
<div className="flex w-full flex-col gap-2 w-[500px]">
<div className="text-lg font-bold leading-[22px]">What's happening?</div>
<div className="relative">
<Textarea.Input
value={tweet}
onValueChange={setTweet}
maxLength={280}
placeholder="Share your thoughts..."
hasError={!!tweetError}
/>
<Textarea.CharCount value={tweet} maxLength={280} />
</div>
{tweetError && (
<div className="text-sm flex items-center gap-1.5 leading-4 text-destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
<span>{tweetError}</span>
</div>
)}
</div>
</div>
{/* Feedback Form */}
<div>
<h3 className="mb-3 text-sm font-semibold">User Feedback</h3>
<div className="flex w-full flex-col gap-2 w-[500px]">
<div className="text-lg font-bold leading-[22px]">
<span className="text-destructive mr-1">*</span>Feedback
</div>
<Textarea.Input
value={feedback}
onValueChange={setFeedback}
placeholder="Please share your thoughts..."
rows={4}
/>
<div className="text-sm flex items-center gap-1.5 leading-4 text-foreground-muted">
Your feedback helps us improve
</div>
</div>
</div>
{/* Contact Form */}
<div>
<h3 className="mb-3 text-sm font-semibold">Contact Us</h3>
<div className="flex w-full flex-col gap-2 w-[500px]">
<div className="text-lg font-bold leading-[22px]">
<span className="text-destructive mr-1">*</span>Message
</div>
<Textarea.Input
value={message}
onValueChange={setMessage}
placeholder="How can we help you?"
rows={6}
hasError={!!messageError}
/>
{messageError ? (
<div className="text-sm flex items-center gap-1.5 leading-4 text-destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
<span>{messageError}</span>
</div>
) : (
<div className="text-sm flex items-center gap-1.5 leading-4 text-foreground-muted">
Minimum 10 characters required
</div>
)}
</div>
</div>
</div>
)
}
}
// Dark Mode
export const DarkMode: Story = {
render: () => (
<div className="dark rounded-lg bg-background p-8">
<div className="flex flex-col gap-6">
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Default (Dark)</div>
<Textarea.Input placeholder="Dark mode textarea..." />
</div>
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">With Content (Dark)</div>
<Textarea.Input defaultValue="This is some content in dark mode" />
</div>
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px]">Error (Dark)</div>
<Textarea.Input hasError />
<div className="text-sm flex items-center gap-1.5 leading-4 text-destructive">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
<span>Error in dark mode</span>
</div>
</div>
<div className="flex w-full flex-col gap-2 w-[400px]">
<div className="text-lg font-bold leading-[22px] cursor-not-allowed opacity-70">Disabled (Dark)</div>
<Textarea.Input defaultValue="Disabled in dark mode" disabled />
</div>
</div>
</div>
)
}
// Composition Example
export const CompositionExample: Story = {
render: function CompositionExampleRender() {
const [bio, setBio] = useState('')
return (
<div className="flex w-full flex-col gap-2 w-[500px]">
<div className="text-lg font-bold leading-[22px]">
<span className="text-destructive mr-1">*</span>Profile Bio
</div>
<div className="relative">
<Textarea.Input value={bio} onValueChange={setBio} placeholder="Tell us about yourself..." maxLength={500} />
<Textarea.CharCount value={bio} maxLength={500} />
</div>
<div className="text-sm flex items-center gap-1.5 leading-4 text-foreground-muted">
This will be displayed on your profile (max 500 characters)
</div>
</div>
)
}
}