New components: - Accordion: compound component with single/multiple mode, grid-rows animation - Textarea: multi-line input with auto-resize, character count, outlined/stacked variants - Select: custom dropdown with keyboard navigation, combobox/listbox ARIA pattern - Card: 4 variants (surface/outlined/elevated/filled) with header action support Changes: - Fix Input/Textarea focus ring layout shift (ring-1 instead of border-2) - Add small/xsmall sizes to IconButton for card action contexts - Add --radius-xl token (16px) for larger container corners Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
198 lines
5.8 KiB
TypeScript
198 lines
5.8 KiB
TypeScript
import { forwardRef, useId, useCallback, useRef, useEffect, type TextareaHTMLAttributes } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
label: string
|
|
description?: string
|
|
hint?: string
|
|
error?: string
|
|
variant?: 'outlined' | 'stacked'
|
|
resize?: 'vertical' | 'horizontal' | 'both' | 'none'
|
|
autoResize?: boolean
|
|
}
|
|
|
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
(
|
|
{
|
|
label,
|
|
description,
|
|
hint,
|
|
error,
|
|
variant = 'outlined',
|
|
resize = 'vertical',
|
|
autoResize = false,
|
|
disabled,
|
|
readOnly,
|
|
maxLength,
|
|
value,
|
|
defaultValue,
|
|
rows = 3,
|
|
className,
|
|
id: idProp,
|
|
onChange,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const autoId = useId()
|
|
const id = idProp ?? autoId
|
|
const descriptionId = `${id}-description`
|
|
const hintId = `${id}-hint`
|
|
const hasError = !!error
|
|
const supportiveText = error || hint
|
|
const isStacked = variant === 'stacked'
|
|
|
|
const internalRef = useRef<HTMLTextAreaElement | null>(null)
|
|
|
|
const currentLength =
|
|
maxLength != null && typeof value === 'string' ? value.length : undefined
|
|
|
|
const describedBy =
|
|
[
|
|
description && isStacked ? descriptionId : undefined,
|
|
supportiveText ? hintId : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ') || undefined
|
|
|
|
const resizeClass: Record<string, string> = {
|
|
vertical: 'resize-y',
|
|
horizontal: 'resize-x',
|
|
both: 'resize',
|
|
none: 'resize-none',
|
|
}
|
|
|
|
const adjustHeight = useCallback(() => {
|
|
const el = internalRef.current
|
|
if (!el || !autoResize) return
|
|
el.style.height = 'auto'
|
|
el.style.height = `${el.scrollHeight}px`
|
|
}, [autoResize])
|
|
|
|
useEffect(() => {
|
|
adjustHeight()
|
|
}, [adjustHeight, value])
|
|
|
|
const setRefs = useCallback(
|
|
(node: HTMLTextAreaElement | null) => {
|
|
internalRef.current = node
|
|
if (typeof ref === 'function') ref(node)
|
|
else if (ref) ref.current = node
|
|
},
|
|
[ref],
|
|
)
|
|
|
|
return (
|
|
<div className={cn('flex w-full flex-col', isStacked ? 'gap-1.5' : 'gap-1 pt-2', className)}>
|
|
{isStacked && (
|
|
<div className="flex flex-col gap-0.5">
|
|
<label
|
|
htmlFor={id}
|
|
className={cn(
|
|
'text-small font-bold',
|
|
hasError ? 'text-control-error' : 'text-control-label',
|
|
disabled && 'text-control-description',
|
|
)}
|
|
>
|
|
{label}
|
|
</label>
|
|
{description && (
|
|
<p
|
|
id={descriptionId}
|
|
className={cn('text-small text-text', disabled && 'opacity-50')}
|
|
>
|
|
{description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
'relative flex rounded-[4px] border bg-control-bg transition-colors',
|
|
hasError
|
|
? 'border-control-error focus-within:ring-1 focus-within:ring-control-error'
|
|
: 'border-control-border hover:border-control-border-hover focus-within:border-control-checked focus-within:ring-1 focus-within:ring-control-checked',
|
|
disabled && 'pointer-events-none border-control-border/50 bg-control-bg/50',
|
|
readOnly && 'border-transparent bg-control-bg-readonly',
|
|
)}
|
|
>
|
|
{!isStacked && (
|
|
<label
|
|
htmlFor={id}
|
|
className={cn(
|
|
'absolute left-2 top-0 z-10 -translate-y-1/2 bg-control-bg px-1 text-small font-bold leading-none',
|
|
hasError ? 'text-control-error' : 'text-control-label',
|
|
disabled && 'text-control-description',
|
|
)}
|
|
>
|
|
{label}
|
|
</label>
|
|
)}
|
|
|
|
<textarea
|
|
ref={setRefs}
|
|
id={id}
|
|
disabled={disabled}
|
|
readOnly={readOnly}
|
|
maxLength={maxLength}
|
|
value={value}
|
|
defaultValue={defaultValue}
|
|
rows={rows}
|
|
aria-invalid={hasError || undefined}
|
|
aria-describedby={describedBy}
|
|
onChange={(e) => {
|
|
onChange?.(e)
|
|
adjustHeight()
|
|
}}
|
|
className={cn(
|
|
'w-full bg-transparent px-3 py-3 text-body font-normal text-text outline-none',
|
|
'placeholder:text-text/50',
|
|
autoResize ? 'resize-none overflow-hidden' : resizeClass[resize],
|
|
disabled && 'text-text/50',
|
|
)}
|
|
{...props}
|
|
/>
|
|
</div>
|
|
|
|
{(supportiveText || currentLength != null) && (
|
|
<div
|
|
id={hintId}
|
|
className={cn(
|
|
'flex items-center gap-1 text-small',
|
|
hasError ? 'text-control-error' : 'text-control-description',
|
|
disabled && 'opacity-50',
|
|
)}
|
|
>
|
|
{hasError && (
|
|
<svg
|
|
className="size-4 shrink-0"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" y1="8" x2="12" y2="12" />
|
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
</svg>
|
|
)}
|
|
{supportiveText && <p className="flex-1">{supportiveText}</p>}
|
|
{currentLength != null && (
|
|
<p className="shrink-0 text-right">
|
|
{currentLength}/{maxLength}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
},
|
|
)
|
|
|
|
Textarea.displayName = 'Textarea'
|