/** * @title Text area * @description 여러 줄에 걸친 긴 텍스트를 입력할 수 있는 입력 영역이에요. */import * as React from 'react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/textarea — text-area.js 해독 스펙// 플래그 ON(최신): platform-dst-tokens-finesse → placeholder text.subtle,// platform-dst-motion-uplift-input → 상호작용 가능한(비활성·읽기 전용 아님) 필드에만 150ms --ds-input 전환const lineHeightBase = 20;const lineHeightCompact = 16;const compactVerticalPadding = 2;const verticalPadding = 6;const borderWidth = 2;type Appearance = 'standard' | 'subtle' | 'none';type Resize = 'auto' | 'vertical' | 'horizontal' | 'smart' | 'none';const FOCUS_RING = 'focus:shadow-[inset_0_0_0_var(--ds-border-width)_var(--ds-border-focused)]';const HOVER = 'hover:not-read-only:not-focus';const baseClass = cn( 'relative m-0 box-border block w-full min-w-0 max-w-full shrink grow basis-full overflow-auto break-words', 'rounded-ds-medium border-(length:--ds-border-width) ds-font-body text-ds-text outline-none', 'leading-[1.4285714285714286] data-compact:leading-[1.1428571428571428]', 'placeholder:text-ds-text-subtle invalid:shadow-none [&::-ms-clear]:hidden', 'disabled:cursor-not-allowed disabled:text-ds-text-disabled disabled:placeholder:text-ds-text-disabled disabled:opacity-100', // 터치 + 좁은 화면에서는 16px(font.body.large) 로 iOS 자동 확대를 막아요. 'pointer-coarse:max-[30rem]:ds-font-body-large',);const motionClass = 'transition-[background-color,border-color,box-shadow] duration-(--ds-motion-input-duration) ease-(--ds-motion-input-curve)';const appearanceClass: Record<Appearance, string> = { standard: cn( 'border-solid px-[7px] py-ds-075 data-compact:py-ds-025', 'not-focus:border-ds-border-input not-focus:bg-ds-background-input', 'disabled:not-focus:border-ds-border-disabled disabled:not-focus:bg-ds-background-disabled', `${HOVER}:border-ds-border-input ${HOVER}:bg-ds-background-input-hovered`, 'focus:border-ds-border-focused focus:bg-ds-background-input-pressed', FOCUS_RING, 'disabled:focus:bg-ds-background-disabled', ), subtle: cn( 'border-solid px-[7px] py-ds-075 data-compact:py-ds-025', 'not-focus:border-transparent not-focus:bg-transparent', `${HOVER}:border-ds-border-input ${HOVER}:bg-ds-background-input-hovered`, 'focus:border-ds-border-focused focus:bg-ds-background-input-pressed', FOCUS_RING, ), none: cn( 'border-none px-ds-075 py-ds-075 data-compact:py-ds-025', 'not-focus:border-transparent not-focus:bg-transparent', 'focus:border-transparent focus:bg-transparent', ),};// 오류: 포커스 전에는 danger 테두리 + 안쪽 1px 그림자, 포커스하면 focused 로 바뀌어요.const invalidClass = cn( 'not-focus:border-ds-border-danger not-focus:bg-ds-background-input', 'not-focus:shadow-[inset_0_0_0_var(--ds-border-width)_var(--ds-border-danger)]', `${HOVER}:border-ds-border-danger ${HOVER}:bg-ds-background-input-hovered`, 'focus:border-ds-border-focused focus:bg-ds-background-input-pressed', FOCUS_RING,);const resizeClass: Record<Resize, string> = { horizontal: 'resize-x', vertical: 'resize-y', auto: 'resize', smart: 'resize-none', none: 'resize-none',};// smart: 내용 높이(scrollHeight) + 위아래 테두리로 높이를 맞춰요.const setSmartHeight = (el: HTMLTextAreaElement) => { el.style.height = 'auto'; el.style.height = `${el.scrollHeight + borderWidth * 2}px`;};export interface TextAreaProps extends Omit<React.ComponentProps<'textarea'>, 'rows'> { /** 필드 모양. subtle 은 hover 때만 스타일이 보이고, none 은 모든 필드 스타일을 없애요. */ appearance?: Appearance; /** 줄 높이·여백을 줄여요. */ isCompact?: boolean; /** 비활성. 편집·포커스할 수 없어요. */ isDisabled?: boolean; /** 값을 바꿀 수 없게 해요. */ isReadOnly?: boolean; /** 폼 제출 시 필수 값이에요. */ isRequired?: boolean; /** 값이 잘못됐음을 테두리로 표시해요. */ isInvalid?: boolean; /** 고정폭 글꼴로 보여줘요. */ isMonospaced?: boolean; /** 처음 보이는 최소 줄 수 */ minimumRows?: number; /** 최대 높이 (CSS 값) */ maxHeight?: string; /** * 크기 조절 방식. `smart` 는 입력에 맞춰 세로로 자동으로 늘고 줄어요. * `auto` 는 가로·세로, `vertical`/`horizontal` 은 한 방향만, `none` 은 고정이에요. */ resize?: Resize; testId?: string;}function TextArea({ resize = 'smart', appearance = 'standard', isCompact = false, isRequired = false, isReadOnly = false, isDisabled: isDisabledProp = false, isInvalid: isInvalidProp = false, isMonospaced = false, minimumRows = 2, maxHeight = '50vh', testId, value, style, onChange, className, ref, disabled, readOnly, required, ...props}: TextAreaProps) { const ourRef = React.useRef<HTMLTextAreaElement | null>(null); // Form 의 Field 가 aria-invalid / disabled 를 넘겨주면 그대로 반영해요. const isDisabled = isDisabledProp || !!disabled; const ariaInvalid = props['aria-invalid']; const isInvalid = isInvalidProp || ariaInvalid === true || ariaInvalid === 'true'; const borderHeight = appearance === 'none' ? 2 : 1; React.useEffect(() => { if (resize === 'smart' && ourRef.current) setSmartHeight(ourRef.current); }, [resize, value]); const setRef = React.useCallback( (el: HTMLTextAreaElement | null) => { ourRef.current = el; if (typeof ref === 'function') ref(el); else if (ref) ref.current = el; }, [ref], ); const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => { if (resize === 'smart' && ourRef.current) setSmartHeight(ourRef.current); onChange?.(event); }; const minHeight = isCompact ? lineHeightCompact * minimumRows + compactVerticalPadding * 2 + borderHeight * 2 : lineHeightBase * minimumRows + verticalPadding * 2 + borderHeight * 2; return ( <textarea data-slot="textarea" data-appearance={appearance} data-invalid={isInvalid || undefined} data-compact={isCompact || undefined} data-testid={testId} value={value} disabled={isDisabled} readOnly={isReadOnly || readOnly} required={isRequired || required} ref={setRef} onChange={handleChange} rows={minimumRows} {...props} aria-invalid={isInvalid || undefined} style={{ ...style, maxHeight, minHeight: `${minHeight}px` }} className={cn( baseClass, !isDisabled && !(isReadOnly || readOnly) && motionClass, appearanceClass[appearance], isInvalid && invalidClass, resizeClass[resize], isMonospaced && 'font-ds-code!', className, )} /> );}export { TextArea };