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>
This commit is contained in:
197
src/components/ui/Textarea/Textarea.tsx
Normal file
197
src/components/ui/Textarea/Textarea.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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'
|
||||
Reference in New Issue
Block a user