/** * @title Textfield * @description 한 줄짜리 텍스트를 입력하거나 고칠 수 있는 입력 필드예요. */import * as React from 'react';import { cn } from '@/lib/utils';// Atlassian @atlaskit/textfield — text-field.js 해독 스펙// 컨테이너: border 1px + 내부 padding-block 1px, radius.medium, font.body// 포커스: border.focused + 안쪽 1px box-shadow(=2px 테두리처럼 보임), background.input.pressed// 오류: border.danger + 안쪽 1px danger box-shadow, 포커스하면 focused 색으로 바뀜// 플래그 ON(최신): platform-dst-tokens-finesse → placeholder text.subtle,// platform-dst-motion-uplift-input → 상호작용 가능한(비활성·읽기 전용 아님) 필드에만 150ms --ds-input 전환type Appearance = 'standard' | 'subtle' | 'none';const widthMap = { xsmall: 80, small: 160, medium: 240, large: 320, xlarge: 480 } as const;const getMaxWidth = (width?: string | number) => !width ? '100%' : width in widthMap ? `${widthMap[width as keyof typeof widthMap]}px` : `${+width}px`;// hover 는 포커스 중이 아닐 때만 (포커스 스타일이 이긴다)const HOVER = '[&:hover:not(:has(input:focus))]';const FOCUS = '[&:has(input:focus)]';const containerBase = [ 'box-border flex shrink grow basis-full items-center justify-between overflow-hidden align-top break-words', 'rounded-ds-medium border-(length:--ds-border-width) ds-font-body text-ds-text cursor-text pointer-events-auto',];// platform-dst-motion-uplift-input: isInteractive 일 때만 전환을 건다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: 'border-solid border-ds-border-input bg-ds-background-input py-(--ds-border-width) px-0', subtle: 'border-solid border-transparent bg-transparent py-(--ds-border-width) px-0', none: 'border-none bg-transparent',};const interactiveClass: Record<Appearance, string> = { standard: cn( `${HOVER}:border-ds-border-input ${HOVER}:bg-ds-background-input-hovered`, `${FOCUS}:border-ds-border-focused ${FOCUS}:bg-ds-background-input-pressed`, `${FOCUS}:shadow-[inset_0_0_0_var(--ds-border-width)_var(--ds-border-focused)]`, ), subtle: cn( `${HOVER}:border-ds-border-input ${HOVER}:bg-ds-background-input-hovered`, `${FOCUS}:border-ds-border-focused ${FOCUS}:bg-ds-background-input-pressed`, `${FOCUS}:shadow-[inset_0_0_0_var(--ds-border-width)_var(--ds-border-focused)]`, ), none: '',};const disabledClass: Record<Appearance, string> = { standard: 'border-ds-background-disabled bg-ds-background-disabled text-ds-text-disabled cursor-not-allowed', subtle: 'border-transparent bg-transparent text-ds-text-disabled cursor-not-allowed', none: 'border-transparent bg-transparent text-ds-text-disabled cursor-not-allowed',};const invalidClass = cn( 'border-ds-border-danger shadow-[inset_0_0_0_var(--ds-border-width)_var(--ds-border-danger)]', `${HOVER}:border-ds-border-danger`, `${FOCUS}:bg-ds-background-input-pressed`,);const inputClass = cn( 'box-border w-full min-w-0 border-0 bg-transparent text-inherit cursor-[inherit] outline-none ds-font-body', 'px-ds-075 py-ds-100 placeholder:text-ds-text-subtle placeholder-shown:text-ellipsis', 'invalid:shadow-none [&::-ms-clear]:hidden', // iOS 자동 확대를 막기 위해 터치 + 좁은 화면에서는 16px(font.body.large) 'pointer-coarse:max-[30rem]:ds-font-body-large pointer-coarse:max-[30rem]:py-ds-075',);export interface TextfieldProps extends Omit<React.ComponentProps<'input'>, 'width'> { /** 필드 모양. subtle 은 hover 때만 스타일이 보이고, none 은 모든 필드 스타일을 없애요. */ appearance?: Appearance; /** 작은 높이(32px)로 줄여요. */ isCompact?: boolean; /** 비활성. 편집·포커스할 수 없어요. 가능하면 검증·오류 메시지를 먼저 고려해요. */ isDisabled?: boolean; /** 값이 잘못됐음을 테두리로 표시해요. */ isInvalid?: boolean; /** 고정폭 글꼴로 보여줘요. */ isMonospaced?: boolean; /** 값을 바꿀 수 없게 해요. */ isReadOnly?: boolean; /** 폼 제출 시 필수 값이에요. */ isRequired?: boolean; /** 입력 앞에 붙는 요소 (아이콘 등, 상호작용 없는 요소) */ elemBeforeInput?: React.ReactNode; /** 입력 뒤에 붙는 요소 */ elemAfterInput?: React.ReactNode; /** 최대 너비. `xsmall`(80) `small`(160) `medium`(240) `large`(320) `xlarge`(480) 또는 숫자(px) */ width?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | (string & {}) | number; /** 컨테이너를 눌렀을 때 호출돼요. */ onMouseDown?: React.MouseEventHandler<HTMLElement>; /** input 에 data-testid, 컨테이너에 `${testId}-container` 로 들어가요. */ testId?: string; /** 컨테이너 className */ className?: string;}function Textfield({ appearance = 'standard', className, elemAfterInput, elemBeforeInput, isCompact = false, isDisabled: isDisabledProp = false, isInvalid: isInvalidProp = false, isMonospaced = false, isReadOnly = false, isRequired = false, onMouseDown, testId, width, ref, disabled, readOnly, required, style, ...props}: TextfieldProps) { const inputRef = React.useRef<HTMLInputElement | null>(null); // Form 의 Field 가 aria-invalid / disabled 를 넘겨주면 그대로 반영해요. const isDisabled = isDisabledProp || !!disabled; const ariaInvalid = props['aria-invalid']; const isInvalid = isInvalidProp || ariaInvalid === true || ariaInvalid === 'true'; const setRef = React.useCallback( (el: HTMLInputElement | null) => { inputRef.current = el; if (typeof ref === 'function') ref(el); else if (ref) ref.current = el; }, [ref], ); const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => { // 컨테이너(앞뒤 요소 포함) 어디를 눌러도 input 에 포커스 if ((event.target as HTMLElement).tagName !== 'INPUT') event.preventDefault(); if (inputRef.current && !isDisabled && document.activeElement !== inputRef.current) { inputRef.current.focus(); } onMouseDown?.(event); }; return ( <div role="presentation" data-slot="textfield" data-appearance={appearance} data-disabled={isDisabled || undefined} data-invalid={isInvalid || undefined} data-testid={testId && `${testId}-container`} onMouseDown={handleMouseDown} style={{ maxWidth: getMaxWidth(width) }} className={cn( containerBase, !isDisabled && !(isReadOnly || readOnly) && motionClass, appearanceClass[appearance], !isDisabled && interactiveClass[appearance], isDisabled && disabledClass[appearance], isInvalid && invalidClass, className, )} > {elemBeforeInput} <input {...props} ref={setRef} data-slot="textfield-input" data-compact={isCompact || undefined} data-monospaced={isMonospaced || undefined} data-testid={testId} aria-invalid={isInvalid || undefined} disabled={isDisabled} readOnly={isReadOnly || readOnly} required={isRequired || required} style={style} className={cn( inputClass, isCompact && 'py-ds-050 pointer-coarse:max-[30rem]:py-ds-025', isMonospaced && 'font-ds-code!', isDisabled && 'text-ds-text-disabled placeholder:text-ds-text-disabled', )} /> {elemAfterInput} </div> );}export { Textfield };