Skip to main content
Glama
textarea.stories.tsx21.8 kB
import type { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; import { useState } from 'react'; import { InputVariant } from '../Input'; import { TextArea } from '.'; /** * ## TextArea Component * * A flexible multi-line text input component that extends standard HTML textarea * functionality with design system styling, validation states, and consistent theming. * * ### Key Features * - **Multiple Variants**: Default, invisible, error, success, and warning styles * - **Validation Integration**: Built-in validation styling with `validationStyleEnabled` * - **Consistent Theming**: Matches design system color palette and spacing * - **Accessibility**: Full support for screen readers and keyboard navigation * - **Responsive**: Adapts to different screen sizes and containers * * ### Use Cases * - Form fields requiring multi-line input * - Comment and feedback systems * - Content creation interfaces * - Message composition * - Description and notes fields * * ### Accessibility Features * - Proper label association via `htmlFor` or `aria-labelledby` * - Error messaging with `aria-describedby` and `aria-invalid` * - Keyboard navigation and focus management * - High contrast validation colors */ const meta: Meta<typeof TextArea> = { title: 'Components/TextArea', component: TextArea, parameters: { layout: 'centered', docs: { description: { component: 'A flexible multi-line text input component with validation states and design system integration.', }, }, }, tags: ['autodocs'], argTypes: { placeholder: { description: 'Placeholder text displayed when textarea is empty', control: 'text', table: { type: { summary: 'string' }, }, }, value: { description: 'Current value for controlled component', control: 'text', table: { type: { summary: 'string' }, }, }, defaultValue: { description: 'Initial content for uncontrolled component', control: 'text', table: { type: { summary: 'string' }, }, }, rows: { description: 'Number of visible text lines', control: { type: 'number', min: 1, max: 20 }, table: { type: { summary: 'number' }, defaultValue: { summary: '3' }, }, }, cols: { description: 'Number of visible character columns', control: { type: 'number', min: 10, max: 100 }, table: { type: { summary: 'number' }, }, }, disabled: { description: 'Whether the textarea is disabled', control: 'boolean', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, }, }, readOnly: { description: 'Whether the textarea is read-only', control: 'boolean', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, }, }, variant: { description: 'Visual variant affecting appearance and styling', control: { type: 'select' }, options: Object.values(InputVariant), table: { type: { summary: 'InputVariant' }, defaultValue: { summary: 'InputVariant.DEFAULT' }, }, }, validationStyleEnabled: { description: 'Enable validation styling based on aria-invalid attribute', control: 'boolean', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, }, }, maxLength: { description: 'Maximum number of characters allowed', control: { type: 'number', min: 1 }, table: { type: { summary: 'number' }, }, }, className: { description: 'Additional CSS classes for custom styling', control: 'text', }, }, } satisfies Meta<typeof TextArea>; export default meta; type Story = StoryObj<typeof meta>; /** * ## Basic Examples * * Fundamental usage patterns for common scenarios. */ /** * ### Default TextArea * * Standard textarea with default styling and basic configuration. */ export const Default: Story = { render: (args) => ( <div className="w-full max-w-md space-y-3"> <TextArea {...args} /> <div className="text-gray-600 text-sm"> Standard textarea with default styling, perfect for most form applications. </div> </div> ), args: { placeholder: 'Type your message here...', rows: 4, variant: InputVariant.DEFAULT, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const textarea = canvas.getByPlaceholderText('Type your message here...'); await expect(textarea).toBeInTheDocument(); await expect(textarea).toHaveAttribute('rows', '4'); }, }; /** * ### Size and Layout Variations * * Different dimensions and layout configurations for various use cases. */ export const SizeVariations: Story = { render: () => ( <div className="w-full max-w-4xl space-y-8"> <div> <h4 className="mb-4 font-semibold text-lg">Different Sizes</h4> <div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div> <label className="mb-2 block font-medium text-sm"> Compact (2 rows) </label> <TextArea placeholder="Brief message..." rows={2} className="w-full" /> </div> <div> <label className="mb-2 block font-medium text-sm"> Standard (4 rows) </label> <TextArea placeholder="Standard message length..." rows={4} className="w-full" /> </div> <div> <label className="mb-2 block font-medium text-sm"> Large (8 rows) </label> <TextArea placeholder="Long form content..." rows={8} className="w-full" /> </div> <div> <label className="mb-2 block font-medium text-sm"> Fixed Width (40 cols) </label> <TextArea placeholder="Fixed column width..." rows={4} cols={40} className="font-mono text-sm" /> </div> </div> </div> <div className="max-w-2xl text-gray-500 text-xs"> Choose appropriate sizes based on expected content length. Use `cols` for fixed-width layouts like code editors, and `rows` for content height. </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Test different sizes are rendered const compactTextarea = canvas.getByPlaceholderText('Brief message...'); const standardTextarea = canvas.getByPlaceholderText( 'Standard message length...' ); await expect(compactTextarea).toHaveAttribute('rows', '2'); await expect(standardTextarea).toHaveAttribute('rows', '4'); }, }; /** * ## Visual Variants * * Different styling variants for various contexts and states. */ /** * ### Styling Variants * * Available visual variants showing different appearance options. */ export const StyleVariants: Story = { render: () => ( <div className="w-full max-w-3xl space-y-6"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div> <label className="mb-2 block font-medium text-sm">Default</label> <TextArea placeholder="Default styling with borders..." variant={InputVariant.DEFAULT} rows={3} /> </div> <div> <label className="mb-2 block font-medium text-sm">Invisible</label> <TextArea placeholder="Minimal styling, no borders..." variant={InputVariant.INVISIBLE} rows={3} className="bg-gray-50" /> </div> </div> <div className="rounded-lg border border-blue-200 bg-blue-50 p-4"> <div className="mb-2 font-medium text-blue-900 text-sm"> Variant Usage Guide </div> <ul className="space-y-1 text-blue-800 text-sm"> <li> • <strong>Default:</strong> Standard form fields with clear boundaries </li> <li> • <strong>Invisible:</strong> Inline editing, seamless content integration </li> </ul> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const defaultTextarea = canvas.getByPlaceholderText( 'Default styling with borders...' ); const invisibleTextarea = canvas.getByPlaceholderText( 'Minimal styling, no borders...' ); await expect(defaultTextarea).toBeInTheDocument(); await expect(invisibleTextarea).toBeInTheDocument(); }, }; /** * ## Validation States * * Examples showing validation feedback and error handling. */ /** * ### Validation Styling * * Textareas with validation states for form feedback. */ export const ValidationStates: Story = { render: () => ( <div className="w-full max-w-2xl space-y-6"> <div className="space-y-4"> <div> <label className="mb-2 block font-medium text-sm">Valid Input</label> <TextArea defaultValue="This is a valid message that meets all requirements." validationStyleEnabled={true} aria-invalid={false} rows={3} className="w-full" /> <div className="mt-1 text-green-600 text-sm"> ✓ Message looks good! </div> </div> <div> <label className="mb-2 block font-medium text-sm"> Invalid Input </label> <TextArea defaultValue="Too short" validationStyleEnabled={true} aria-invalid={true} rows={3} className="w-full" /> <div className="mt-1 text-red-600 text-sm"> ✗ Message must be at least 20 characters long. </div> </div> <div> <label className="mb-2 block font-medium text-sm"> With Character Limit </label> <TextArea placeholder="Maximum 100 characters..." maxLength={100} rows={3} className="w-full" /> <div className="mt-1 text-gray-500 text-sm"> Characters remaining will be shown as you type. </div> </div> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const validTextarea = canvas.getByDisplayValue( 'This is a valid message that meets all requirements.' ); const invalidTextarea = canvas.getByDisplayValue('Too short'); await expect(validTextarea).toHaveAttribute('aria-invalid', 'false'); await expect(invalidTextarea).toHaveAttribute('aria-invalid', 'true'); }, }; /** * ## Interactive Examples * * Real-world usage scenarios with dynamic behavior. */ /** * ### Interactive Form Field * * Complete form field example with validation and character counting. */ export const InteractiveFormField: Story = { render: () => { const [message, setMessage] = useState(''); const [error, setError] = useState(''); const maxLength = 200; const validateMessage = (value: string) => { if (value.length < 10) { setError('Message must be at least 10 characters long'); } else if (value.length > maxLength) { setError(`Message cannot exceed ${maxLength} characters`); } else { setError(''); } }; const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const value = e.target.value; setMessage(value); validateMessage(value); }; const handleSubmit = () => { if (!error && message.length >= 10) { alert(`Message submitted: "${message}"`); setMessage(''); setError(''); } }; const remainingChars = maxLength - message.length; const isValid = !error && message.length >= 10; return ( <div className="w-full max-w-lg space-y-4"> <div className="space-y-2"> <label htmlFor="feedback-message" className="block font-semibold text-gray-700 text-sm" > Your Feedback </label> <TextArea id="feedback-message" value={message} onChange={handleChange} placeholder="Share your thoughts with us..." rows={4} maxLength={maxLength} validationStyleEnabled={true} aria-invalid={!!error} aria-describedby={error ? 'feedback-error' : 'feedback-help'} className="w-full" /> <div className="flex items-center justify-between text-sm"> <div> {error ? ( <span id="feedback-error" className="text-red-600" role="alert"> {error} </span> ) : ( <span id="feedback-help" className="text-gray-500"> Minimum 10 characters required </span> )} </div> <span className={`text-sm ${ remainingChars < 20 ? 'font-medium text-red-500' : 'text-gray-400' }`} > {remainingChars} remaining </span> </div> </div> <button onClick={handleSubmit} disabled={!isValid} className={`w-full rounded-md px-4 py-2 font-medium transition-colors ${ isValid ? 'bg-blue-600 text-white hover:bg-blue-700' : 'cursor-not-allowed bg-gray-300 text-gray-500' }`} > Submit Feedback </button> <div className="rounded-lg border border-gray-200 bg-gray-50 p-3"> <div className="mb-1 font-medium text-gray-700 text-xs"> Form State </div> <div className="space-y-1 text-gray-600 text-xs"> <div> Characters: {message.length}/{maxLength} </div> <div>Valid: {isValid ? '✓ Yes' : '✗ No'}</div> <div>Error: {error || 'None'}</div> </div> </div> </div> ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const textarea = canvas.getByLabelText('Your Feedback'); // Test typing and validation await userEvent.type(textarea, 'Hello'); await expect( canvas.getAllByText(/must be at least 10 characters/)[0] ).toBeInTheDocument(); // Type more to clear error await userEvent.type(textarea, ' world! This is a test.'); await expect(canvas.getAllByText('Error: None')[0]).toBeInTheDocument(); }, }; /** * ### Comment System Example * * A realistic comment form with threading and interaction features. */ export const CommentSystem: Story = { render: () => { const [comments, setComments] = useState([ { id: 1, author: 'Sarah Chen', content: 'Great article! I particularly liked the section about accessibility best practices.', timestamp: '2 hours ago', replies: [ { id: 11, author: 'Mike Johnson', content: 'Agreed! The WCAG examples were very helpful.', timestamp: '1 hour ago', }, ], }, ]); const [newComment, setNewComment] = useState(''); const [replyingTo, setReplyingTo] = useState<number | null>(null); const [replyContent, setReplyContent] = useState(''); const addComment = () => { if (newComment.trim().length < 3) return; const comment = { id: Date.now(), author: 'You', content: newComment, timestamp: 'just now', replies: [], }; setComments([...comments, comment]); setNewComment(''); }; const addReply = (commentId: number) => { if (replyContent.trim().length < 3) return; const reply = { id: Date.now(), author: 'You', content: replyContent, timestamp: 'just now', }; setComments( comments.map((comment) => comment.id === commentId ? { ...comment, replies: [...comment.replies, reply] } : comment ) ); setReplyContent(''); setReplyingTo(null); }; return ( <div className="w-full max-w-2xl space-y-6"> <div className="rounded-lg border border-gray-200 bg-white p-6"> <h3 className="mb-4 font-semibold text-lg"> Comments ({comments.length}) </h3> {/* New Comment Form */} <div className="mb-6 space-y-3"> <TextArea value={newComment} onChange={(e) => setNewComment(e.target.value)} placeholder="Join the discussion..." rows={3} className="w-full" maxLength={500} /> <div className="flex items-center justify-between"> <span className="text-gray-500 text-sm"> {500 - newComment.length} characters remaining </span> <button onClick={addComment} disabled={newComment.trim().length < 3} className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-300" > Post Comment </button> </div> </div> {/* Comments List */} <div className="space-y-4"> {comments.map((comment) => ( <div key={comment.id} className="border-gray-200 border-l-2 pl-4"> <div className="space-y-2"> <div className="flex items-center gap-2"> <span className="font-medium text-gray-900"> {comment.author} </span> <span className="text-gray-500 text-sm"> {comment.timestamp} </span> </div> <p className="text-gray-700">{comment.content}</p> <button onClick={() => setReplyingTo(comment.id)} className="text-blue-600 text-sm hover:text-blue-800" > Reply </button> </div> {/* Replies */} {comment.replies.length > 0 && ( <div className="mt-3 ml-4 space-y-2"> {comment.replies.map((reply) => ( <div key={reply.id} className="border-gray-100 border-l py-2 pl-3" > <div className="mb-1 flex items-center gap-2"> <span className="font-medium text-gray-900 text-sm"> {reply.author} </span> <span className="text-gray-500 text-xs"> {reply.timestamp} </span> </div> <p className="text-gray-700 text-sm">{reply.content}</p> </div> ))} </div> )} {/* Reply Form */} {replyingTo === comment.id && ( <div className="mt-3 ml-4 space-y-2"> <TextArea value={replyContent} onChange={(e) => setReplyContent(e.target.value)} placeholder={`Reply to ${comment.author}...`} rows={2} className="w-full" maxLength={300} /> <div className="flex gap-2"> <button onClick={() => addReply(comment.id)} disabled={replyContent.trim().length < 3} className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:bg-gray-300" > Reply </button> <button onClick={() => { setReplyingTo(null); setReplyContent(''); }} className="rounded border border-gray-300 px-3 py-1 text-gray-600 text-sm hover:bg-gray-50" > Cancel </button> </div> </div> )} </div> ))} </div> </div> </div> ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Test comment system const commentTextarea = canvas.getByPlaceholderText( 'Join the discussion...' ); await expect(commentTextarea).toBeInTheDocument(); // Test reply functionality const replyButton = canvas.getByText('Reply'); await userEvent.click(replyButton); const replyTextarea = canvas.getByPlaceholderText(/Reply to Sarah Chen/); await expect(replyTextarea).toBeInTheDocument(); }, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server