Files
ADS3-Design-System/src/components/ui/Textarea/Textarea.tsx
Richie 4be996789e Add Accordion, Textarea, Select, and Card components; fix Input focus shift
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>
2026-05-21 17:10:11 +10:00

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'