/** * @title Select * @description 목록에서 하나 또는 여러 항목을 고르고, 입력해서 옵션을 걸러 볼 수 있는 선택 필드예요. */import * as React from 'react';import { Popover as PopoverPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { ChevronDown, X } from 'lucide-react';import { cn } from '@/lib/utils';/* ------------------------------------------------------------------------------------------------- * 타입 (@atlaskit/select · react-select 와 같은 이름) * -----------------------------------------------------------------------------------------------*/export interface OptionType { label: string; value: string | number; isDisabled?: boolean; [key: string]: unknown;}export interface GroupType<Option = OptionType> { label: string; options: readonly Option[];}export type OptionsOrGroups<Option = OptionType> = readonly (Option | GroupType<Option>)[];export type ValueType<Option = OptionType, IsMulti extends boolean = false> = IsMulti extends true ? readonly Option[] : Option | null;export type ValidationState = 'default' | 'error' | 'success';export interface ActionMeta<Option = OptionType> { action: 'select-option' | 'deselect-option' | 'remove-value' | 'pop-value' | 'clear'; option?: Option; removedValue?: Option; removedValues?: readonly Option[]; name?: string;}export interface FilterOptionOption<Option = OptionType> { label: string; value: string; data: Option;}export interface FormatOptionLabelMeta<Option = OptionType> { context: 'menu' | 'value'; inputValue: string; selectValue: readonly Option[];}export interface SelectProps<Option = OptionType, IsMulti extends boolean = false> extends Omit<React.ComponentProps<'div'>, 'onChange' | 'defaultValue' | 'placeholder' | 'children'> { /** 옵션 목록. `{ label, options }` 그룹을 섞어 넣을 수 있어요. */ options?: OptionsOrGroups<Option>; value?: ValueType<Option, IsMulti>; defaultValue?: ValueType<Option, IsMulti>; onChange?: (value: ValueType<Option, IsMulti>, actionMeta: ActionMeta<Option>) => void; /** 여러 개를 고를 수 있게 해요. 선택값은 태그로 보여요. */ isMulti?: IsMulti; /** 입력해서 옵션을 걸러 볼 수 있어요. */ isSearchable?: boolean; /** 선택값을 지우는 버튼을 보여줘요. */ isClearable?: boolean; isLoading?: boolean; isDisabled?: boolean; isInvalid?: boolean; /** @deprecated `isInvalid` 를 쓰세요. */ validationState?: ValidationState; placeholder?: React.ReactNode; appearance?: 'default' | 'subtle' | 'none'; spacing?: 'default' | 'compact'; /** 입력 요소(combobox)의 id. 라벨의 htmlFor 와 연결해요. */ inputId?: string; name?: string; inputValue?: string; onInputChange?: (newValue: string, actionMeta: { action: 'input-change' | 'set-value' | 'menu-close' | 'input-blur' }) => void; menuIsOpen?: boolean; defaultMenuIsOpen?: boolean; onMenuOpen?: () => void; onMenuClose?: () => void; openMenuOnFocus?: boolean; closeMenuOnSelect?: boolean; hideSelectedOptions?: boolean; backspaceRemovesValue?: boolean; /** 메뉴 최대 높이(px) */ maxMenuHeight?: number; noOptionsMessage?: (obj: { inputValue: string }) => React.ReactNode; loadingMessage?: (obj: { inputValue: string }) => React.ReactNode; formatOptionLabel?: (data: Option, meta: FormatOptionLabelMeta<Option>) => React.ReactNode; formatGroupLabel?: (group: GroupType<Option>) => React.ReactNode; getOptionLabel?: (option: Option) => string; getOptionValue?: (option: Option) => string; isOptionDisabled?: (option: Option, selectValue: readonly Option[]) => boolean; filterOption?: ((option: FilterOptionOption<Option>, inputValue: string) => boolean) | null; onFocus?: React.FocusEventHandler<HTMLInputElement>; onBlur?: React.FocusEventHandler<HTMLInputElement>; autoFocus?: boolean; 'aria-label'?: string; 'aria-labelledby'?: string; 'aria-describedby'?: string; /** 도움말 요소 id (aria-describedby 로 연결) */ descriptionId?: string; testId?: string;}/* ------------------------------------------------------------------------------------------------- * 스타일 — @atlaskit/react-select components/*.compiled 스펙 * -----------------------------------------------------------------------------------------------*/const focusRing = 'shadow-[inset_0_0_0_var(--ds-border-width,1px)_var(--ds-border-focused)]';const controlVariants = cva( [ 'relative box-border flex flex-wrap items-center justify-between cursor-default outline-none', 'rounded-ds-medium border-solid border-(length:--ds-border-width) border-ds-border-input bg-ds-background-input p-ds-0', 'transition-[background-color,border-color,box-shadow] duration-150 ease-[cubic-bezier(0.4,1,0.6,1)]', 'hover:cursor-pointer hover:border-ds-border-input hover:bg-ds-background-input-hovered', ], { variants: { appearance: { default: '', subtle: 'border-transparent bg-transparent', none: 'border-transparent bg-transparent hover:border-transparent hover:bg-transparent', }, spacing: { default: 'min-h-10', compact: 'min-h-8', }, state: { idle: '', invalid: 'border-ds-border-danger shadow-[inset_0_0_0_var(--ds-border-width,1px)_var(--ds-border-danger)] hover:border-ds-border-danger', focused: cn( 'border-ds-border-focused bg-ds-background-input-pressed hover:border-ds-border-focused hover:bg-ds-background-input-pressed', focusRing, ), disabled: 'pointer-events-none border-ds-background-disabled bg-ds-background-disabled', }, }, compoundVariants: [ { appearance: 'none', state: 'focused', className: 'border-transparent bg-transparent shadow-none hover:border-transparent hover:bg-transparent', }, ], defaultVariants: { appearance: 'default', spacing: 'default', state: 'idle' }, },);const optionVariants = cva( [ 'block w-full cursor-default select-none px-ds-150 py-ds-075 text-ds-text', 'transition-[background-color,border-color,color] duration-[50ms] ease-[cubic-bezier(0.4,1,0.6,1)]', 'active:bg-ds-background-neutral-subtle-pressed', ], { variants: { focused: { true: 'border-s-(length:--ds-border-width-selected) border-solid border-transparent bg-ds-background-neutral-subtle-hovered shadow-[inset_2px_0_0_var(--ds-border-selected)]', false: '', }, selected: { true: 'border-s-(length:--ds-border-width-selected) border-solid border-transparent bg-ds-background-selected text-ds-text-selected shadow-[inset_2px_0_0_var(--ds-border-selected)] active:bg-ds-background-selected-pressed', false: '', }, disabled: { true: 'cursor-not-allowed border-s-0 bg-transparent text-ds-text-disabled shadow-none active:bg-transparent', false: '', }, }, compoundVariants: [{ focused: true, selected: true, disabled: false, className: 'bg-ds-background-selected-hovered' }], defaultVariants: { focused: false, selected: false, disabled: false }, },);/* ------------------------------------------------------------------------------------------------- * CheckboxSelect / RadioSelect 옵션 — @atlaskit/select components/control-option.js 해독 스펙 * 플래그 ON(최신): platform-dst-tokens-finesse → 포커스된 옵션에 왼쪽 2px 막대(inset box-shadow)가 없어요. * -----------------------------------------------------------------------------------------------*/type ControlOptionKind = 'checkbox' | 'radio';/** CheckboxSelect / RadioSelect 가 Select 에 옵션 모양을 알려주는 내부 컨텍스트 */const ControlOptionContext = React.createContext<ControlOptionKind | null>(null);const controlOptionVariants = cva( [ 'box-border flex w-full cursor-default select-none items-center bg-transparent py-ds-050 ps-ds-200 pe-ds-150 text-inherit', '[-webkit-tap-highlight-color:transparent] active:bg-ds-background-neutral-subtle-pressed', ], { variants: { focused: { true: 'bg-ds-background-neutral-subtle-hovered shadow-none', false: '' }, disabled: { true: 'text-ds-text-disabled', false: 'transition-[background-color,border-color,color] duration-[50ms] ease-[cubic-bezier(0.4,1,0.6,1)] active:duration-100', }, }, defaultVariants: { focused: false, disabled: false }, },);interface ControlState { isActive: boolean; isDisabled: boolean; isFocused: boolean; isSelected: boolean;}// 바깥(상자·원) 색const getPrimaryColor = ({ isActive, isDisabled, isFocused, isSelected }: ControlState) => { if (isDisabled) return 'var(--ds-background-disabled)'; if (isSelected && isActive) return 'var(--ds-background-selected-bold-pressed)'; if (isActive) return 'var(--ds-background-selected-pressed)'; if (isFocused && isSelected) return 'var(--ds-background-selected-bold-hovered)'; if (isFocused) return 'var(--ds-surface)'; if (isSelected) return 'var(--ds-background-selected-bold)'; return 'var(--ds-background-neutral)';};// 체크 표시·라디오 점 색const getSecondaryColor = ({ isDisabled, isSelected }: Pick<ControlState, 'isDisabled' | 'isSelected'>) => { if (isDisabled && isSelected) return 'var(--ds-text-disabled)'; if (!isSelected) return 'transparent'; return 'var(--ds-surface)';};function ControlOptionIcon({ kind, ...state }: ControlState & { kind: ControlOptionKind }) { const strokeInherit = state.isSelected || state.isActive || state.isDisabled; return ( <span data-slot="select-option-control" aria-hidden className={cn( 'flex shrink-0 items-center pe-ds-050', '[&_circle:first-of-type]:[stroke-linejoin:round] [&_rect]:[stroke-linejoin:round]', '[&_circle:first-of-type]:[stroke-width:var(--ds-border-width,1px)] [&_rect]:[stroke-width:var(--ds-border-width,1px)]', strokeInherit ? '[&_circle:first-of-type]:stroke-current [&_rect]:stroke-current' : '[&_circle:first-of-type]:stroke-ds-border-bold [&_rect]:stroke-ds-border-bold', )} > <svg viewBox="0 0 24 24" role="presentation" className="pointer-events-none size-6 overflow-hidden" style={{ color: getPrimaryColor(state), fill: getSecondaryColor(state) }} > {kind === 'checkbox' ? ( <g fillRule="evenodd"> <rect x="5.5" y="5.5" width="13" height="13" rx="1.5" fill="currentColor" /> <path fillRule="evenodd" clipRule="evenodd" d="M16.3262 9.48011L15.1738 8.51984L10.75 13.8284L8.82616 11.5198L7.67383 12.4801L10.1738 15.4801C10.3163 15.6511 10.5274 15.75 10.75 15.75C10.9726 15.75 11.1837 15.6511 11.3262 15.4801L16.3262 9.48011Z" fill="inherit" /> </g> ) : ( <g fillRule="evenodd"> <circle cx="12" cy="12" r="6.75" fill="currentColor" strokeWidth="1.5" /> <circle cx="12" cy="12" r="3" fill="inherit" /> </g> )} </svg> </span> );}interface ControlOptionRowProps extends React.ComponentProps<'div'> { kind: ControlOptionKind; isFocused: boolean; isSelected: boolean; isDisabled: boolean;}function ControlOptionRow({ kind, isFocused, isSelected, isDisabled, children, className, ...props }: ControlOptionRowProps) { const [isActive, setIsActive] = React.useState(false); return ( <div {...props} data-focused={isFocused || undefined} onMouseDown={() => setIsActive(true)} onMouseUp={() => setIsActive(false)} onMouseLeave={() => setIsActive(false)} className={cn(controlOptionVariants({ focused: isFocused, disabled: isDisabled }), className)} > <ControlOptionIcon kind={kind} isActive={isActive} isDisabled={isDisabled} isFocused={isFocused} isSelected={isSelected} /> <div className="grow truncate">{children}</div> </div> );}/* ------------------------------------------------------------------------------------------------- * 유틸 * -----------------------------------------------------------------------------------------------*/function isGroup<Option>(item: Option | GroupType<Option>): item is GroupType<Option> { return typeof item === 'object' && item !== null && Array.isArray((item as GroupType<Option>).options);}const defaultGetLabel = (option: unknown) => String((option as OptionType).label ?? '');const defaultGetValue = (option: unknown) => String((option as OptionType).value ?? '');const defaultFilter = (option: FilterOptionOption<unknown>, input: string) => { const needle = input.trim().toLowerCase(); if (!needle) return true; return `${option.label} ${option.value}`.toLowerCase().includes(needle);};function toArray<Option>(value: unknown): readonly Option[] { if (value == null) return []; return Array.isArray(value) ? (value as readonly Option[]) : [value as Option];}function Spinner({ className }: { className?: string }) { return ( <svg viewBox="0 0 16 16" className={cn('size-4 animate-spin', className)} fill="none" aria-hidden> <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" /> <path d="M14.5 8A6.5 6.5 0 0 0 8 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> </svg> );}/* ------------------------------------------------------------------------------------------------- * 하위 표시 컴포넌트 (react-select 의 MultiValue / Group heading 등) * -----------------------------------------------------------------------------------------------*/interface MultiValueTagProps { label: React.ReactNode; removeLabel: string; isDisabled?: boolean; onRemove: () => void;}/** 다중 선택값 태그 — @atlaskit/tag (new) 모양 */function MultiValueTag({ label, removeLabel, isDisabled, onRemove }: MultiValueTagProps) { return ( <span data-slot="select-multi-value" className={cn( 'relative box-border inline-flex h-5 min-w-0 max-w-[11.25rem] shrink items-center gap-ds-050 self-center', 'rounded-ds-small border-solid border-(length:--ds-border-width) border-ds-border-accent-gray-subtle bg-ds-background-neutral-subtle', 'py-ds-025 ps-[0.1875rem] ds-font-body-small text-ds-text', isDisabled && 'bg-ds-background-neutral text-ds-text-disabled', )} > <span className="min-w-0 truncate ps-ds-025">{label}</span> {!isDisabled && ( <button type="button" tabIndex={-1} aria-label={removeLabel} // 포커스가 입력에서 빠지지 않게 한다 onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.stopPropagation(); onRemove(); }} className={cn( 'me-ds-025 flex cursor-pointer items-center justify-center self-center rounded-ds-small border-none bg-ds-background-neutral-subtle p-ds-025 text-ds-icon', 'hover:bg-ds-background-neutral-subtle-hovered hover:text-ds-text-danger active:bg-ds-background-neutral-subtle-pressed active:text-ds-text-danger', )} > <X className="size-3" aria-hidden /> </button> )} </span> );}/* ------------------------------------------------------------------------------------------------- * Select * -----------------------------------------------------------------------------------------------*/function Select<Option = OptionType, IsMulti extends boolean = false>({ options = [], value: valueProp, defaultValue, onChange, isMulti, isSearchable = true, isClearable = false, isLoading = false, isDisabled = false, isInvalid, validationState = 'default', placeholder = '선택...', appearance = 'default', spacing = 'default', inputId, name, inputValue: inputValueProp, onInputChange, menuIsOpen: menuIsOpenProp, defaultMenuIsOpen = false, onMenuOpen, onMenuClose, openMenuOnFocus = false, closeMenuOnSelect = true, hideSelectedOptions, backspaceRemovesValue = true, maxMenuHeight = 300, noOptionsMessage = () => '옵션이 없어요', loadingMessage = () => '불러오는 중...', formatOptionLabel, formatGroupLabel, getOptionLabel = defaultGetLabel as (option: Option) => string, getOptionValue = defaultGetValue as (option: Option) => string, isOptionDisabled, filterOption = defaultFilter as (option: FilterOptionOption<Option>, inputValue: string) => boolean, onFocus, onBlur, autoFocus, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, descriptionId, testId, className, ...props}: SelectProps<Option, IsMulti>) { const controlOption = React.useContext(ControlOptionContext); const multi = Boolean(isMulti); const invalid = isInvalid || validationState === 'error'; const hideSelected = hideSelectedOptions ?? multi; const reactId = React.useId(); const baseId = inputId ?? `steel-select-${reactId.replace(/:/g, '')}`; const listboxId = `${baseId}-listbox`; const inputRef = React.useRef<HTMLInputElement>(null); const controlRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null); // 값 (controlled / uncontrolled) const [innerValue, setInnerValue] = React.useState<ValueType<Option, IsMulti> | undefined>(defaultValue); const isValueControlled = valueProp !== undefined; const currentValue = isValueControlled ? valueProp : innerValue; const selected = React.useMemo(() => toArray<Option>(currentValue), [currentValue]); const selectedKeys = React.useMemo(() => new Set(selected.map((o) => getOptionValue(o))), [selected, getOptionValue]); // 입력값 const [innerInput, setInnerInput] = React.useState(''); const inputValue = inputValueProp ?? innerInput; const setInput = (next: string, action: 'input-change' | 'set-value' | 'menu-close' | 'input-blur') => { if (inputValueProp === undefined) setInnerInput(next); onInputChange?.(next, { action }); }; // 메뉴 열림 const [innerOpen, setInnerOpen] = React.useState(defaultMenuIsOpen); const open = !isDisabled && (menuIsOpenProp ?? innerOpen); const setOpen = (next: boolean) => { if (next === open) return; if (menuIsOpenProp === undefined) setInnerOpen(next); if (next) onMenuOpen?.(); else onMenuClose?.(); }; const [isFocused, setIsFocused] = React.useState(false); const [focusedIndex, setFocusedIndex] = React.useState(-1); const commit = (next: readonly Option[], meta: ActionMeta<Option>) => { const out = (multi ? next : (next[0] ?? null)) as ValueType<Option, IsMulti>; if (!isValueControlled) setInnerValue(out); onChange?.(out, { ...meta, name }); }; const isDisabledOption = (option: Option) => Boolean((option as OptionType | null)?.isDisabled) || Boolean(isOptionDisabled?.(option, selected)); // 필터링된 옵션 → 메뉴 구조 + 키보드 이동용 평탄 목록 const { sections, flat } = React.useMemo(() => { const keep = (option: Option) => { if (hideSelected && selectedKeys.has(getOptionValue(option))) return false; if (!filterOption) return true; return filterOption({ label: getOptionLabel(option), value: getOptionValue(option), data: option }, inputValue); }; const flatList: Option[] = []; const out: { group?: GroupType<Option>; items: { option: Option; index: number }[] }[] = []; let loose: { option: Option; index: number }[] | null = null; for (const item of options) { if (isGroup(item)) { loose = null; const items = item.options.filter(keep).map((option) => ({ option, index: flatList.push(option) - 1 })); if (items.length) out.push({ group: item, items }); } else if (keep(item)) { if (!loose) { loose = []; out.push({ items: loose }); } loose.push({ option: item, index: flatList.push(item) - 1 }); } } return { sections: out, flat: flatList }; }, [options, inputValue, filterOption, hideSelected, selectedKeys, getOptionLabel, getOptionValue]); // 옵션 배열이 매 렌더 새로 만들어져도 포커스가 튀지 않도록 내용 기준 키를 쓴다 const flatKey = flat.map((o) => getOptionValue(o)).join('\u0000'); const firstEnabled = (from: number, dir: 1 | -1) => { if (!flat.length) return -1; let i = from; for (let n = 0; n < flat.length; n++) { i = (i + flat.length) % flat.length; if (!isDisabledOption(flat[i])) return i; i += dir; } return -1; }; // 메뉴가 열리거나 목록이 바뀌면 포커스 옵션을 다시 잡는다 React.useEffect(() => { if (!open) { setFocusedIndex(-1); return; } const selectedIndex = !multi && selected[0] ? flat.findIndex((o) => getOptionValue(o) === getOptionValue(selected[0])) : -1; setFocusedIndex(selectedIndex >= 0 ? selectedIndex : firstEnabled(0, 1)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, flatKey]); React.useEffect(() => { if (!open || focusedIndex < 0) return; const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${focusedIndex}"]`); el?.scrollIntoView?.({ block: 'nearest' }); }, [open, focusedIndex]); React.useEffect(() => { if (autoFocus) inputRef.current?.focus(); }, [autoFocus]); const selectOption = (option: Option) => { if (isDisabledOption(option)) return; const key = getOptionValue(option); if (multi) { if (selectedKeys.has(key)) { commit( selected.filter((o) => getOptionValue(o) !== key), { action: 'deselect-option', option }, ); } else { commit([...selected, option], { action: 'select-option', option }); } } else { commit([option], { action: 'select-option', option }); } if (inputValue) setInput('', 'set-value'); if (closeMenuOnSelect) setOpen(false); }; const removeValue = (option: Option) => { const key = getOptionValue(option); commit( selected.filter((o) => getOptionValue(o) !== key), { action: 'remove-value', removedValue: option }, ); inputRef.current?.focus(); }; const clearValue = () => { commit([], { action: 'clear', removedValues: selected }); setInput('', 'set-value'); inputRef.current?.focus(); }; const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (isDisabled) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); if (!open) setOpen(true); else setFocusedIndex((i) => firstEnabled(i + 1, 1)); break; case 'ArrowUp': e.preventDefault(); if (!open) setOpen(true); else setFocusedIndex((i) => firstEnabled((i < 0 ? flat.length : i) - 1, -1)); break; case 'Home': case 'End': if (!open) return; e.preventDefault(); setFocusedIndex(e.key === 'Home' ? firstEnabled(0, 1) : firstEnabled(flat.length - 1, -1)); break; case 'PageDown': case 'PageUp': if (!open) return; e.preventDefault(); setFocusedIndex((i) => e.key === 'PageDown' ? firstEnabled(Math.min(flat.length - 1, i + 5), -1) : firstEnabled(Math.max(0, i - 5), 1), ); break; case 'Enter': if (!open) return; e.preventDefault(); if (focusedIndex >= 0 && flat[focusedIndex]) selectOption(flat[focusedIndex]); break; case ' ': if (inputValue && isSearchable) return; e.preventDefault(); if (!open) setOpen(true); else if (focusedIndex >= 0 && flat[focusedIndex]) selectOption(flat[focusedIndex]); break; case 'Escape': if (open) { e.preventDefault(); setOpen(false); if (inputValue) setInput('', 'menu-close'); } else if (isClearable && selected.length) { clearValue(); } break; case 'Tab': if (open) setOpen(false); break; case 'Backspace': case 'Delete': if (inputValue || !backspaceRemovesValue || !selected.length) return; e.preventDefault(); if (multi) { const last = selected[selected.length - 1]; commit(selected.slice(0, -1), { action: 'pop-value', removedValue: last }); } else if (isClearable) { clearValue(); } break; default: break; } }; const handleControlMouseDown = (e: React.MouseEvent<HTMLDivElement>) => { if (isDisabled || e.button !== 0) return; const target = e.target as HTMLElement; const focused = document.activeElement === inputRef.current; if (target !== inputRef.current) e.preventDefault(); inputRef.current?.focus(); // 입력 영역 클릭은 메뉴를 열기만, 그 외(화살표 등)는 토글 if (!focused || !open) setOpen(true); else if (target !== inputRef.current || !isSearchable) setOpen(false); }; const showSingle = !multi && selected.length > 0 && !inputValue; const showPlaceholder = selected.length === 0 && !inputValue; const hasChips = multi && selected.length > 0; const activeId = open && focusedIndex >= 0 ? `${baseId}-option-${focusedIndex}` : undefined; const compact = spacing === 'compact'; const state = isDisabled ? 'disabled' : isFocused ? 'focused' : invalid ? 'invalid' : 'idle'; const describedBy = [ariaDescribedBy, descriptionId].filter(Boolean).join(' ') || undefined; const renderLabel = (option: Option, context: 'menu' | 'value') => formatOptionLabel ? formatOptionLabel(option, { context, inputValue, selectValue: selected }) : getOptionLabel(option); return ( <PopoverPrimitive.Root open={open} onOpenChange={(next) => setOpen(next)}> <div data-slot="select" data-testid={testId ? `${testId}-select--container` : undefined} className={cn('relative pointer-events-auto ds-font-body', isDisabled && 'cursor-not-allowed', className)} {...props} > <PopoverPrimitive.Anchor asChild> <div ref={controlRef} data-slot="select-control" data-testid={testId ? `${testId}-select--control` : undefined} data-focused={isFocused || undefined} data-invalid={invalid || undefined} data-disabled={isDisabled || undefined} data-menu-open={open || undefined} onMouseDown={handleControlMouseDown} className={controlVariants({ appearance, spacing, state })} > {/* 값 영역 */} <div data-slot="select-value-container" className={cn( 'relative grid flex-1 basis-0 items-center overflow-hidden px-ds-075', multi ? (compact ? 'py-ds-025' : 'py-ds-050') : compact ? 'py-ds-0' : 'py-ds-025', hasChips && 'flex flex-wrap gap-ds-050', )} > {showPlaceholder && ( <div data-slot="select-placeholder" className={cn( 'col-start-1 row-start-1 mx-ds-025 truncate text-ds-text-subtlest', isDisabled && 'text-ds-text-disabled', )} > {placeholder} </div> )} {showSingle && ( <div data-slot="select-single-value" className={cn( 'col-start-1 row-start-1 mx-ds-025 max-w-full truncate text-ds-text', isDisabled && 'text-ds-text-disabled', )} > {renderLabel(selected[0], 'value')} </div> )} {hasChips && selected.map((option) => { const label = getOptionLabel(option); return ( <MultiValueTag key={getOptionValue(option)} label={renderLabel(option, 'value')} removeLabel={`${label} 제거`} isDisabled={isDisabled} onRemove={() => removeValue(option)} /> ); })} <div className={cn( 'relative col-start-1 row-start-1 m-ds-025 flex py-ds-025 text-ds-text', hasChips ? 'min-w-8 flex-1' : 'w-full', isDisabled && 'invisible', )} > <input ref={inputRef} id={baseId} role="combobox" type="text" autoComplete="off" autoCapitalize="none" spellCheck={false} aria-autocomplete="list" aria-expanded={open} aria-haspopup="listbox" aria-controls={open ? listboxId : undefined} aria-activedescendant={activeId} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} aria-describedby={describedBy} aria-invalid={invalid || undefined} aria-disabled={isDisabled || undefined} disabled={isDisabled} readOnly={!isSearchable} data-testid={testId ? `${testId}-select--input` : undefined} value={isSearchable ? inputValue : ''} onChange={(e) => { setInput(e.target.value, 'input-change'); setOpen(true); }} onKeyDown={handleKeyDown} onFocus={(e) => { setIsFocused(true); if (openMenuOnFocus) setOpen(true); onFocus?.(e); }} onBlur={(e) => { setIsFocused(false); setOpen(false); if (inputValue) setInput('', 'input-blur'); onBlur?.(e); }} className={cn( 'm-0 w-full min-w-[2px] border-0 bg-transparent p-0 text-inherit outline-none [font:inherit]', !isSearchable && 'cursor-[inherit] caret-transparent', )} /> </div> </div> {/* 인디케이터 */} <div data-slot="select-indicators" className="flex shrink-0 items-center self-stretch pe-ds-050" > {isClearable && selected.length > 0 && !isDisabled && ( <div data-slot="select-clear-indicator" aria-hidden onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); if (e.button === 0) clearValue(); }} className={cn( 'flex cursor-pointer px-ds-025 py-ds-075 text-ds-icon-subtle transition-colors duration-150 hover:text-ds-icon', compact && 'py-ds-0', )} > <span className="flex items-center justify-center p-ds-025"> <X className="size-3" /> </span> </div> )} {isLoading && ( <div data-slot="select-loading-indicator" aria-hidden className={cn('flex px-ds-100 py-ds-075 text-ds-icon-subtle', compact && 'py-ds-0')} > <Spinner /> </div> )} <div data-slot="select-dropdown-indicator" aria-hidden className={cn( 'flex px-ds-025 py-ds-075 text-ds-icon-subtle transition-colors duration-150', compact && 'py-ds-0', isDisabled && 'text-ds-icon-disabled', )} > <span className="flex p-ds-075"> <ChevronDown className="size-3" strokeWidth={2.5} /> </span> </div> </div> </div> </PopoverPrimitive.Anchor> {name && (selected.length ? selected : [null]).map((option, i) => ( <input key={i} type="hidden" name={name} value={option ? getOptionValue(option) : ''} /> ))} </div> <PopoverPrimitive.Portal> <PopoverPrimitive.Content data-slot="select-menu" data-testid={testId ? `${testId}-select--listbox-container` : undefined} align="start" sideOffset={8} collisionPadding={8} onOpenAutoFocus={(e) => e.preventDefault()} // Esc 는 입력의 onKeyDown 이 처리한다 onEscapeKeyDown={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} onInteractOutside={(e) => { if (controlRef.current?.contains(e.target as Node)) e.preventDefault(); }} onMouseDown={(e) => e.preventDefault()} className={cn( 'z-50 w-(--radix-popover-trigger-width) rounded-ds-large bg-ds-surface-overlay shadow-ds-overlay ds-font-body outline-none', 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0', )} > <div ref={listRef} id={listboxId} role="listbox" aria-multiselectable={multi || undefined} aria-labelledby={ariaLabelledBy} aria-label={ariaLabelledBy ? undefined : ariaLabel} tabIndex={-1} data-slot="select-menu-list" style={{ maxHeight: maxMenuHeight }} className="relative overflow-y-auto rounded-ds-large py-ds-100" > {isLoading && flat.length === 0 ? ( <div className="px-ds-150 py-ds-100 text-center text-ds-text-subtle">{loadingMessage({ inputValue })}</div> ) : flat.length === 0 ? ( <div className="px-ds-150 py-ds-100 text-center text-ds-text-subtle">{noOptionsMessage({ inputValue })}</div> ) : ( sections.map((section, s) => { const items = section.items.map(({ option, index }) => { const disabled = isDisabledOption(option); const isSelected = selectedKeys.has(getOptionValue(option)); if (controlOption) { return ( <ControlOptionRow key={`${getOptionValue(option)}-${index}`} kind={controlOption} id={`${baseId}-option-${index}`} role="option" data-index={index} data-slot="select-option" aria-selected={isSelected} aria-disabled={disabled || undefined} isFocused={focusedIndex === index} isSelected={isSelected} isDisabled={disabled} onMouseMove={() => !disabled && focusedIndex !== index && setFocusedIndex(index)} onClick={() => selectOption(option)} > {renderLabel(option, 'menu')} </ControlOptionRow> ); } return ( <div key={`${getOptionValue(option)}-${index}`} id={`${baseId}-option-${index}`} role="option" data-index={index} data-slot="select-option" aria-selected={isSelected} aria-disabled={disabled || undefined} onMouseMove={() => !disabled && focusedIndex !== index && setFocusedIndex(index)} onClick={() => selectOption(option)} className={optionVariants({ focused: focusedIndex === index, selected: isSelected, disabled })} > {renderLabel(option, 'menu')} </div> ); }); if (!section.group) return <React.Fragment key={`loose-${s}`}>{items}</React.Fragment>; const headingId = `${baseId}-group-${s}-heading`; return ( <div key={`group-${s}`} role="group" aria-labelledby={headingId} className="py-ds-100"> <div id={headingId} data-slot="select-group-heading" className="mb-[0.25em] block cursor-default px-ds-150 ds-font-body-small font-ds-bold text-ds-text-subtle" > {formatGroupLabel ? formatGroupLabel(section.group) : section.group.label} </div> {items} </div> ); }) )} </div> </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> );}/* ------------------------------------------------------------------------------------------------- * CheckboxSelect / RadioSelect — @atlaskit/select checkbox-select.js · radio-select.js * -----------------------------------------------------------------------------------------------*/export type CheckboxSelectProps<Option = OptionType> = Omit<SelectProps<Option, true>, 'isMulti'>;export type RadioSelectProps<Option = OptionType> = Omit<SelectProps<Option, false>, 'isMulti'>;/** 옵션마다 체크박스가 붙는 다중 선택. 고른 뒤에도 메뉴가 열려 있고, 선택한 옵션도 목록에 남아요. */function CheckboxSelect<Option = OptionType>(props: CheckboxSelectProps<Option>) { return ( <ControlOptionContext.Provider value="checkbox"> <Select<Option, true> closeMenuOnSelect={false} hideSelectedOptions={false} {...props} isMulti /> </ControlOptionContext.Provider> );}/** 옵션마다 라디오 버튼이 붙는 단일 선택. */function RadioSelect<Option = OptionType>(props: RadioSelectProps<Option>) { return ( <ControlOptionContext.Provider value="radio"> <Select<Option, false> {...props} isMulti={false} /> </ControlOptionContext.Provider> );}export { Select, CheckboxSelect, RadioSelect, controlVariants as selectControlVariants, optionVariants as selectOptionVariants,};