/** * @title Text Field * @description 라벨, 설명, 에러 메시지, 글자 수와 함께 텍스트를 입력받는 컴포넌트예요. */'use client';import * as React from 'react';import { Label as LabelPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: text-input.yaml + field.yaml + field-label.yaml// size="responsive" 는 SEED breakpoint lg(1280px) 미만에서 large, 이상에서 medium 이에요.type TextFieldVariant = 'outline' | 'underline';type TextFieldSize = 'large' | 'medium' | 'responsive';/* ------------------------------------------------------------------------------------------------- * Styles * -----------------------------------------------------------------------------------------------*/const textFieldControlVariants = cva( [ 'group/text-field relative flex w-full items-center overflow-hidden', // focused/invalid stroke 는 enabled stroke 위에 ::after 로 fade in/out 돼요. 'after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:border-transparent', 'after:transition-[border-color] after:duration-100 after:ease-easing', ], { variants: { variant: { outline: [ 'shadow-[inset_0_0_0_1px_var(--rb-color-stroke-neutral-weak)]', 'after:border-2 after:border-solid', 'not-data-[readonly]:not-data-[invalid]:focus-within:after:border-stroke-neutral-contrast', 'data-[invalid]:after:border-stroke-critical-solid', 'data-[readonly]:bg-bg-disabled data-[disabled]:bg-bg-disabled', ], underline: [ 'shadow-[inset_0_-1px_0_0_var(--rb-color-stroke-neutral-weak)]', 'after:border-0 after:border-b-2 after:border-solid', 'not-data-[readonly]:not-data-[invalid]:focus-within:after:border-b-stroke-neutral-contrast', 'data-[invalid]:after:border-b-stroke-critical-solid', ], }, size: { large: '', medium: '', responsive: '', }, }, compoundVariants: [ { variant: 'outline', size: 'large', className: 'min-h-x13 gap-x2_5 rounded-r3' }, { variant: 'outline', size: 'medium', className: 'min-h-x10 gap-x2 rounded-r2' }, { variant: 'outline', size: 'responsive', className: 'min-h-x13 gap-x2_5 rounded-r3 min-[1280px]:min-h-x10 min-[1280px]:gap-x2 min-[1280px]:rounded-r2', }, { variant: 'underline', size: 'large', className: 'min-h-x10 gap-x2_5 py-x2' }, { variant: 'underline', size: 'medium', className: 'min-h-[34px] gap-x2 py-x1_5' }, { variant: 'underline', size: 'responsive', className: 'min-h-x10 gap-x2_5 py-x2 min-[1280px]:min-h-[34px] min-[1280px]:gap-x2 min-[1280px]:py-x1_5', }, ], defaultVariants: { variant: 'outline', size: 'large', }, },);const textFieldValueVariants = cva( [ 'box-border min-w-0 grow self-stretch border-none bg-transparent px-0 font-regular text-fg-neutral outline-none', 'resize-none placeholder:font-regular placeholder:text-fg-placeholder', 'autofill:bg-clip-text autofill:[-webkit-text-fill-color:var(--rb-color-fg-neutral)]', 'disabled:cursor-not-allowed disabled:text-fg-disabled disabled:placeholder:text-fg-disabled', ], { variants: { variant: { outline: '', // underline 은 readonly 일 때 값을 흐리게 보여줘요. underline: 'data-[readonly]:not-disabled:text-fg-neutral-muted data-[readonly]:not-disabled:placeholder:text-fg-neutral-muted', }, size: { large: '', medium: '', responsive: '', }, }, compoundVariants: [ { variant: 'outline', size: 'large', className: 'text-t5 first:pl-x4 last:pr-x4' }, { variant: 'outline', size: 'medium', className: 'text-t4 first:pl-x3_5 last:pr-x3_5' }, { variant: 'outline', size: 'responsive', className: 'text-t5 first:pl-x4 last:pr-x4 min-[1280px]:text-t4 min-[1280px]:first:pl-x3_5 min-[1280px]:last:pr-x3_5', }, { variant: 'underline', size: 'large', className: 'text-t6' }, { variant: 'underline', size: 'medium', className: 'text-t5' }, { variant: 'underline', size: 'responsive', className: 'text-t6 min-[1280px]:text-t5' }, ], defaultVariants: { variant: 'outline', size: 'large', }, },);// type=multiline 의 최소 높이와 상하 여백 (size 만 영향)const textFieldTextareaSize: Record<TextFieldSize, string> = { large: 'min-h-[94px] py-x3_5', medium: 'min-h-[82px] py-x3', responsive: 'min-h-[94px] py-x3_5 min-[1280px]:min-h-[82px] min-[1280px]:py-x3',};const textFieldAffixTextVariants = cva( 'shrink-0 font-regular text-fg-neutral-subtle group-data-[disabled]/text-field:text-fg-disabled', { variants: { variant: { outline: '', underline: '' }, size: { large: '', medium: '', responsive: '' }, side: { prefix: '', suffix: '' }, }, compoundVariants: [ { variant: 'outline', size: 'large', className: 'text-t5' }, { variant: 'outline', size: 'medium', className: 'text-t4' }, { variant: 'outline', size: 'responsive', className: 'text-t5 min-[1280px]:text-t4' }, { variant: 'outline', size: 'large', side: 'prefix', className: 'first:ml-x4' }, { variant: 'outline', size: 'medium', side: 'prefix', className: 'first:ml-x3_5' }, { variant: 'outline', size: 'responsive', side: 'prefix', className: 'first:ml-x4 min-[1280px]:first:ml-x3_5' }, { variant: 'outline', size: 'large', side: 'suffix', className: 'last:mr-x4' }, { variant: 'outline', size: 'medium', side: 'suffix', className: 'last:mr-x3_5' }, { variant: 'outline', size: 'responsive', side: 'suffix', className: 'last:mr-x4 min-[1280px]:last:mr-x3_5' }, { variant: 'underline', size: 'large', className: 'text-t6' }, { variant: 'underline', size: 'medium', className: 'text-t5' }, { variant: 'underline', size: 'responsive', className: 'text-t6 min-[1280px]:text-t5' }, ], },);const textFieldAffixIconVariants = cva( 'inline-flex shrink-0 items-center justify-center text-fg-neutral-muted group-data-[disabled]/text-field:text-fg-disabled [&_svg]:size-full', { variants: { variant: { outline: '', underline: '' }, size: { large: '', medium: '', responsive: '' }, side: { prefix: '', suffix: '' }, }, compoundVariants: [ { variant: 'outline', size: 'large', className: 'size-x5' }, { variant: 'outline', size: 'medium', className: 'size-x4' }, { variant: 'outline', size: 'responsive', className: 'size-x5 min-[1280px]:size-x4' }, { variant: 'outline', size: 'large', side: 'prefix', className: 'first:ml-x4' }, { variant: 'outline', size: 'medium', side: 'prefix', className: 'first:ml-x3_5' }, { variant: 'outline', size: 'responsive', side: 'prefix', className: 'first:ml-x4 min-[1280px]:first:ml-x3_5' }, { variant: 'outline', size: 'large', side: 'suffix', className: 'last:mr-x4' }, { variant: 'outline', size: 'medium', side: 'suffix', className: 'last:mr-x3_5' }, { variant: 'outline', size: 'responsive', side: 'suffix', className: 'last:mr-x4 min-[1280px]:last:mr-x3_5' }, { variant: 'underline', size: 'large', className: 'size-x6' }, { variant: 'underline', size: 'medium', className: 'size-x5' }, { variant: 'underline', size: 'responsive', className: 'size-x6 min-[1280px]:size-x5' }, ], },);const textFieldLabelVariants = cva('text-t5 text-fg-neutral', { variants: { weight: { medium: 'font-medium', bold: 'font-bold', }, }, defaultVariants: { weight: 'medium', },});/* ------------------------------------------------------------------------------------------------- * Graphemes * -----------------------------------------------------------------------------------------------*/let segmenter: Intl.Segmenter | null | undefined;/** 이모지·한글 조합 문자까지 사람이 보는 글자 단위로 나눠요. */function collectGraphemes(value: string): string[] { if (segmenter === undefined) { segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter() : null; } if (!segmenter) return Array.from(value); return Array.from(segmenter.segment(value), (s) => s.segment);}export interface TextFieldValueChangeDetails { value: string; graphemes: string[]; /** `maxGraphemeCount` 까지 자른 값 */ slicedValue: string; slicedGraphemes: string[];}/* ------------------------------------------------------------------------------------------------- * Context * -----------------------------------------------------------------------------------------------*/interface TextFieldContextValue { inputId: string; describedBy: string | undefined; variant: TextFieldVariant; size: TextFieldSize; value: string; setValue: (value: string) => void; disabled: boolean; readOnly: boolean; invalid: boolean; required: boolean; name: string | undefined;}const TextFieldContext = React.createContext<TextFieldContextValue | null>(null);function useTextFieldContext(component: string) { const context = React.useContext(TextFieldContext); if (!context) { throw new Error(`${component} 는 <TextField> 안에서 사용해야 해요.`); } return context;}const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;/* ------------------------------------------------------------------------------------------------- * Icons * -----------------------------------------------------------------------------------------------*/function RequiredIndicatorIcon({ className, ...props }: React.ComponentProps<'svg'>) { return ( <svg data-slot="text-field-required-indicator" viewBox="0 0 6 6" fill="none" aria-hidden className={cn('ml-0.5 mt-1 inline size-[6px] align-top text-fg-critical', className)} {...props} > <path d="M3.75002 1.55859L4.41318 1.09468C4.75243 0.857361 5.21982 0.939865 5.45732 1.27899C5.69499 1.61836 5.61243 2.08615 5.27295 2.32366L4.30763 2.99902L5.27372 3.67612C5.61285 3.91381 5.69517 4.38137 5.45761 4.72059C5.21999 5.0599 4.7523 5.14233 4.41299 4.90471L3.75002 4.44043V5.25C3.75002 5.66421 3.41423 6 3.00002 6C2.5858 6 2.25002 5.66421 2.25002 5.25V4.44043L1.58704 4.90471C1.24773 5.14233 0.780041 5.0599 0.542418 4.72059C0.304856 4.38137 0.387176 3.91381 0.726309 3.67612L1.6924 2.99902L0.727079 2.32366C0.387603 2.08615 0.305043 1.61836 0.542707 1.27899C0.780206 0.939865 1.2476 0.857361 1.58685 1.09468L2.25002 1.55859V0.75C2.25002 0.335786 2.5858 0 3.00002 0C3.41423 0 3.75002 0.335786 3.75002 0.75V1.55859Z" fill="currentColor" /> </svg> );}function ErrorIcon({ className }: { className?: string }) { return ( <svg viewBox="0 0 24 24" fill="none" aria-hidden className={className}> <path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm-1.1 5.6a1.1 1.1 0 0 1 2.2 0v5.2a1.1 1.1 0 0 1-2.2 0V7.6ZM12 15.1a1.35 1.35 0 1 0 0 2.7 1.35 1.35 0 0 0 0-2.7Z" fill="currentColor" /> </svg> );}/* ------------------------------------------------------------------------------------------------- * TextField * -----------------------------------------------------------------------------------------------*/export interface TextFieldProps extends Omit<React.ComponentProps<'div'>, 'prefix' | 'defaultValue' | 'onChange'> { /** @default 'outline' */ variant?: TextFieldVariant; /** @default 'large' */ size?: TextFieldSize; label?: React.ReactNode; /** @default 'medium' */ labelWeight?: 'medium' | 'bold'; /** 라벨 오른쪽에 붙는 보조 텍스트 (예: "선택") */ indicator?: React.ReactNode; /** 라벨 오른쪽에 필수 표시(*)를 보여줘요. */ showRequiredIndicator?: boolean; /** 헤더 오른쪽 슬롯 (Ghost 버튼, 아이콘 등 보조 액션) */ headerSuffix?: React.ReactNode; prefixIcon?: React.ReactNode; prefix?: React.ReactNode; suffixIcon?: React.ReactNode; suffix?: React.ReactNode; description?: React.ReactNode; /** `invalid` 일 때만 보여요. 설명은 화면에서 숨기고 스크린 리더에는 남겨요. */ errorMessage?: React.ReactNode; /** 글자 수 표시 기준. 지정하면 Footer 오른쪽에 `현재/최대` 를 보여줘요. */ maxGraphemeCount?: number; hideCharacterCount?: boolean; value?: string; defaultValue?: string; onValueChange?: (details: TextFieldValueChangeDetails) => void; disabled?: boolean; readOnly?: boolean; invalid?: boolean; required?: boolean; name?: string; /** 입력 컨테이너(테두리가 있는 영역)에 붙는 className */ controlClassName?: string;}function TextField({ variant = 'outline', size = 'large', label, labelWeight, indicator, showRequiredIndicator, headerSuffix, prefixIcon, prefix, suffixIcon, suffix, description, errorMessage, maxGraphemeCount, hideCharacterCount, value: valueProp, defaultValue = '', onValueChange, disabled = false, readOnly = false, invalid = false, required = false, name, className, controlClassName, children, ...props}: TextFieldProps) { const id = React.useId(); const inputId = `${id}-input`; const descriptionId = `${id}-description`; const errorId = `${id}-error`; const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue); const isControlled = valueProp !== undefined; const value = isControlled ? valueProp : uncontrolledValue; const graphemeCount = React.useMemo(() => collectGraphemes(value).length, [value]); const onValueChangeRef = React.useRef(onValueChange); onValueChangeRef.current = onValueChange; const setValue = React.useCallback( (next: string) => { if (!isControlled) setUncontrolledValue(next); const graphemes = collectGraphemes(next); const slicedGraphemes = maxGraphemeCount === undefined ? graphemes : graphemes.slice(0, maxGraphemeCount); onValueChangeRef.current?.({ value: next, graphemes, slicedValue: slicedGraphemes.join(''), slicedGraphemes, }); }, [isControlled, maxGraphemeCount], ); const renderHeader = !!(label || indicator || showRequiredIndicator || headerSuffix); const renderDescription = !!description; const renderErrorMessage = !!errorMessage && invalid; const renderCount = !hideCharacterCount && maxGraphemeCount !== undefined; const renderFooter = renderDescription || renderErrorMessage || renderCount; const describedBy = [renderDescription && descriptionId, renderErrorMessage && errorId].filter(Boolean).join(' ') || undefined; const context = React.useMemo<TextFieldContextValue>( () => ({ inputId, describedBy, variant, size, value, setValue, disabled, readOnly, invalid, required, name, }), [inputId, describedBy, variant, size, value, setValue, disabled, readOnly, invalid, required, name], ); const stateAttrs = { 'data-disabled': disabled || undefined, 'data-readonly': readOnly || undefined, 'data-invalid': invalid || undefined, 'data-empty': value === '' || undefined, }; return ( <TextFieldContext.Provider value={context}> <div data-slot="text-field" {...stateAttrs} className={cn('relative flex w-full flex-col gap-x2', className)} {...props} > {renderHeader && ( <div data-slot="text-field-header" className="flex items-center justify-between gap-x2_5 px-x0_5"> <LabelPrimitive.Root data-slot="text-field-label" htmlFor={inputId} className={textFieldLabelVariants({ weight: labelWeight })} > {label} {showRequiredIndicator && <RequiredIndicatorIcon />} {indicator && ( <span data-slot="text-field-indicator" className="inline pl-1 align-bottom text-t4 leading-(--text-t5--line-height) font-regular text-fg-neutral-subtle" > {indicator} </span> )} </LabelPrimitive.Root> {headerSuffix} </div> )} <div data-slot="text-field-control" {...stateAttrs} className={cn(textFieldControlVariants({ variant, size }), controlClassName)} > {prefixIcon && ( <span data-slot="text-field-prefix-icon" aria-hidden className={textFieldAffixIconVariants({ variant, size, side: 'prefix' })} > {prefixIcon} </span> )} {prefix && ( <span data-slot="text-field-prefix" className={textFieldAffixTextVariants({ variant, size, side: 'prefix' })}> {prefix} </span> )} {children} {suffix && ( <span data-slot="text-field-suffix" className={textFieldAffixTextVariants({ variant, size, side: 'suffix' })}> {suffix} </span> )} {suffixIcon && ( <span data-slot="text-field-suffix-icon" className={textFieldAffixIconVariants({ variant, size, side: 'suffix' })} > {suffixIcon} </span> )} </div> {renderFooter && ( <div data-slot="text-field-footer" className="flex items-start gap-x2 px-x0_5"> {renderDescription && ( <p id={descriptionId} data-slot="text-field-description" className={cn( 'flex text-t4 font-regular text-fg-neutral-subtle', renderErrorMessage && 'sr-only', )} > {description} </p> )} {renderErrorMessage && ( <p id={errorId} data-slot="text-field-error-message" aria-live="polite" className="flex text-t4 font-regular text-fg-critical" > <ErrorIcon className="mr-x1_5 mt-[calc((var(--rb-line-height-t4)_-_var(--rb-dimension-x4))_/_2)] size-x4 shrink-0" /> <span>{errorMessage}</span> </p> )} {renderCount && ( <div data-slot="text-field-character-count" className="ml-auto shrink-0 whitespace-nowrap text-t4 font-regular" > <span data-empty={graphemeCount === 0 || undefined} data-exceeded={graphemeCount > maxGraphemeCount || undefined} className={cn( invalid ? 'text-fg-critical' : graphemeCount === 0 ? 'text-fg-neutral-subtle' : 'text-fg-neutral', )} > {graphemeCount} </span> <span className={invalid ? 'text-fg-critical' : 'text-fg-neutral-subtle'}>/{maxGraphemeCount}</span> </div> )} </div> )} </div> </TextFieldContext.Provider> );}/* ------------------------------------------------------------------------------------------------- * TextFieldInput * -----------------------------------------------------------------------------------------------*/export interface TextFieldInputProps extends Omit<React.ComponentProps<'input'>, 'value' | 'defaultValue' | 'size'> {}function TextFieldInput({ className, onChange, id, ...props }: TextFieldInputProps) { const ctx = useTextFieldContext('TextFieldInput'); return ( <input data-slot="text-field-input" id={id ?? ctx.inputId} name={ctx.name} value={ctx.value} disabled={ctx.disabled} readOnly={ctx.readOnly} aria-invalid={ctx.invalid || undefined} aria-required={ctx.required || undefined} aria-describedby={ctx.describedBy} data-readonly={ctx.readOnly || undefined} data-invalid={ctx.invalid || undefined} data-empty={ctx.value === '' || undefined} className={cn(textFieldValueVariants({ variant: ctx.variant, size: ctx.size }), 'w-0', className)} onChange={(event) => { onChange?.(event); if (!event.defaultPrevented) ctx.setValue(event.target.value); }} {...props} /> );}/* ------------------------------------------------------------------------------------------------- * TextFieldTextarea * -----------------------------------------------------------------------------------------------*/export interface TextFieldTextareaProps extends Omit<React.ComponentProps<'textarea'>, 'value' | 'defaultValue'> { /** * 입력한 내용에 맞춰 높이가 자동으로 늘어나요. `style.height` 를 지정하면 꺼져요. * @default true */ autoresize?: boolean;}function TextFieldTextarea({ className, onChange, id, ref, autoresize = true, style, ...props}: TextFieldTextareaProps) { const ctx = useTextFieldContext('TextFieldTextarea'); const innerRef = React.useRef<HTMLTextAreaElement | null>(null); const fixedHeight = style?.height; const setRefs = React.useCallback( (node: HTMLTextAreaElement | null) => { innerRef.current = node; if (typeof ref === 'function') ref(node); else if (ref) (ref as React.RefObject<HTMLTextAreaElement | null>).current = node; }, [ref], ); // React Spectrum / SEED 방식: height 를 auto 로 줄였다가 scrollHeight 만큼 다시 늘려요. useIsomorphicLayoutEffect(() => { const input = innerRef.current; if (!input || !autoresize || fixedHeight) return; const prevAlignment = input.style.alignSelf; const prevOverflow = input.style.overflow; const isFirefox = 'MozAppearance' in input.style; if (!isFirefox) input.style.overflow = 'hidden'; // 측정하는 동안 부모 높이를 고정해 바깥 스크롤 위치가 튀지 않게 해요. const parent = input.parentElement; const prevParentMinHeight = parent?.style.minHeight ?? ''; if (parent) parent.style.minHeight = getComputedStyle(parent).height; input.style.alignSelf = 'start'; input.style.height = 'auto'; input.style.height = `${input.scrollHeight + (input.offsetHeight - input.clientHeight)}px`; input.style.overflow = prevOverflow; input.style.alignSelf = prevAlignment; if (parent) parent.style.minHeight = prevParentMinHeight; }, [ctx.value, autoresize, fixedHeight]); return ( <textarea ref={setRefs} data-slot="text-field-textarea" id={id ?? ctx.inputId} name={ctx.name} value={ctx.value} disabled={ctx.disabled} readOnly={ctx.readOnly} aria-invalid={ctx.invalid || undefined} aria-required={ctx.required || undefined} aria-describedby={ctx.describedBy} data-readonly={ctx.readOnly || undefined} data-invalid={ctx.invalid || undefined} data-empty={ctx.value === '' || undefined} style={style} className={cn( textFieldValueVariants({ variant: ctx.variant, size: ctx.size }), textFieldTextareaSize[ctx.size], !autoresize && 'overflow-y-auto', className, )} onChange={(event) => { onChange?.(event); if (!event.defaultPrevented) ctx.setValue(event.target.value); }} {...props} /> );}export { TextField, TextFieldInput, TextFieldTextarea, textFieldControlVariants };