"use client"; import { Fragment, useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode, type Ref, } from "react"; import { cx } from "../../utils"; import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label"; import { FoxSelectOption } from "../fox-select-option"; import { FoxSelectOptionItem } from "../fox-select-option-item"; export type FoxSelectSize = "sm" | "md" | "lg"; export type FoxSelectSelectionMode = "single" | "multiple"; /** 옵션 하나의 데이터. 그리는 일은 `FoxSelectOptionItem`이 한다. */ export interface FoxSelectItem { value: string; label: ReactNode; /** 옵션 라벨 뒤에 붙는 아이콘. 없으면 텍스트만 보여준다. */ icon?: ReactNode; /** 위험한 선택지(시안의 `state=danger`). 글자·아이콘이 danger 색으로 바뀐다. */ danger?: boolean; } export interface FoxSelectProps { size: FoxSelectSize; options: FoxSelectItem[]; /** * 하나만 고를지 여러 개를 고를지 정한다. 값 prop이 계열마다 다르다 — * `single`은 `value`·`defaultValue`·`onValueChange`(문자열), `multiple`은 * `values`·`defaultValues`·`onValuesChange`(문자열 배열)를 쓴다. * * `multiple`이면 트리거에 고른 옵션의 라벨이 **쉼표로 이어져** 보이고, 하나를 골라도 * 목록이 닫히지 않는다 — 여러 개를 연달아 고르는 것이 목적이라 누를 때마다 닫히면 쓸 수 * 없다. 닫는 것은 바깥 클릭·Escape·Tab이다(`FoxChip`의 select와 같은 규칙). */ selectionMode?: FoxSelectSelectionMode; /** 단일 선택에서만 쓴다. 주면 제어 컴포넌트가 된다 — 호출부가 소유하고, select가 스스로 바꾸지 않는다. */ value?: string; /** * 비제어로 쓸 때의 초기 값. `value`를 주면 무시된다. * * 버튼 계열과 달리 값 상태를 안에 둘 수 있게 열어 둔 이유는, 폼 한 칸을 놓기 위해 * 호출부마다 `useState`를 반복하는 비용이 크기 때문이다(사용자 승인). */ defaultValue?: string; /** * 값이 바뀔 때 호출된다. * * ⚠️ 이름을 `onChange`로 바꾸지 않는다. 이 패키지의 기준은 "네이티브 요소를 감싸면 * `onChange`(그 네이티브 것을 `Omit`으로 걷어내고 대신한다), 감싸는 요소가 없는 커스텀 * 위젯이면 `onValueChange`"다. FoxInput·FoxTextArea·FoxRadio는 앞쪽이고, 이 컴포넌트는 * 네이티브 ``는 옵션 목록 자체를 브라우저가 그려서 * 스타일링할 수 없기 때문에, 버튼(트리거) + 리스트박스로 직접 구현한다. * * 버튼 계열과 달리 상태를 갖는다 — 열림 여부와, 비제어로 쓸 때의 값이다. `value`를 주면 * 값은 호출부가 소유하고 열림 여부만 안에 남는다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxSelect({ size, options, selectionMode = "single", value, defaultValue, onValueChange, values, defaultValues, onValuesChange, placeholder = "선택해주세요", error, disabled = false, readOnly = false, label, ariaLabel, requirement, hint, hintIcon, id, className, hidden = false, onHoverChange, ref, }: FoxSelectProps) { const isDisabled = disabled || readOnly; const isMultiple = selectionMode === "multiple"; const [open, setOpen] = useState(false); const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? ""); const [uncontrolledValues, setUncontrolledValues] = useState(defaultValues ?? []); const [activeIndex, setActiveIndex] = useState(-1); const rootRef = useRef(null); const currentValue = value ?? uncontrolledValue; const currentValues = values ?? uncontrolledValues; const isSelected = (optionValue: string) => isMultiple ? currentValues.includes(optionValue) : optionValue === currentValue; // 화살표가 어디서 시작할지 정한다. 다중 선택에서는 켜진 것 중 첫 번째다. const selectedIndex = options.findIndex((option) => isSelected(option.value)); const selectedOptions = options.filter((option) => isSelected(option.value)); // 트리거에 보이는 것. 다중이면 고른 라벨을 쉼표로 잇는다 — 라벨이 ReactNode일 수 있어 // `join`을 쓰지 못하므로 사이에 구분자를 끼워 넣는다. const displayValue = selectedOptions.length === 0 ? ( placeholder ) : isMultiple ? ( selectedOptions.map((option, index) => ( {index > 0 ? ", " : null} {option.label} )) ) : ( selectedOptions[0].label ); const generatedId = useId(); const triggerId = id ?? generatedId; const listboxId = `${triggerId}-listbox`; const labelId = `${triggerId}-label`; // 값이 아니라 순번으로 만든다 — 옵션 값에 공백·특수문자가 들어와도 id가 깨지지 않는다. const optionId = (index: number) => `${triggerId}-opt-${index}`; useEffect(() => { if (!open) return; function handlePointerDown(event: PointerEvent) { if (!rootRef.current?.contains(event.target as Node)) { setOpen(false); } } // 포인터만 보면 Tab으로 빠져나갈 때 리스트가 열린 채 남는다. // // 포커스가 아무 데도 가지 않은 경우(relatedTarget이 null)는 여기서 닫지 않는다 — // 바깥의 포커스 못 받는 곳을 누른 것이고, 그건 위 pointerdown이 이미 처리한다. // 이걸 닫기로 치면 옵션을 누르는 순간에도 닫혀 클릭이 성사되지 않는다. function handleFocusOut(event: FocusEvent) { const next = event.relatedTarget as Node | null; if (next && !rootRef.current?.contains(next)) { setOpen(false); } } document.addEventListener("pointerdown", handlePointerDown); const root = rootRef.current; root?.addEventListener("focusout", handleFocusOut); return () => { document.removeEventListener("pointerdown", handlePointerDown); root?.removeEventListener("focusout", handleFocusOut); }; }, [open]); function openList() { setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); setOpen(true); } /** * 제어 컴포넌트일 때는 내부 값을 건드리지 않는다 — 건드리면 호출부가 값을 거부해도 * 내부에만 반영되어 둘이 조용히 갈라진다. */ function commit(next: string) { if (isMultiple) { // 이미 켜져 있으면 끈다. 순서는 `options`가 정한 순서를 따라가게 다시 추린다 — // 누른 순서대로 쌓으면 같은 조합인데도 배열이 달라져 비교·저장이 흔들리고, 트리거에 // 이어 붙는 글자 순서도 목록과 어긋난다. const nextValues = currentValues.includes(next) ? currentValues.filter((item) => item !== next) : options .filter((option) => option.value === next || currentValues.includes(option.value)) .map((option) => option.value); if (values === undefined) setUncontrolledValues(nextValues); onValuesChange?.(nextValues); // 목록을 닫지 않는다 — 여러 개를 연달아 고르는 것이 목적이다. return; } if (value === undefined) setUncontrolledValue(next); onValueChange?.(next); setOpen(false); } function moveActive(delta: number) { setActiveIndex((current) => { const count = options.length; if (count === 0) return current; return (current + delta + count) % count; }); } function handleTriggerKeyDown(event: KeyboardEvent) { if (isDisabled) return; if (!open) { if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") { event.preventDefault(); openList(); } return; } switch (event.key) { case "ArrowDown": event.preventDefault(); moveActive(1); break; case "ArrowUp": event.preventDefault(); moveActive(-1); break; case "Home": event.preventDefault(); setActiveIndex(0); break; case "End": event.preventDefault(); setActiveIndex(options.length - 1); break; case "Enter": case " ": event.preventDefault(); if (activeIndex >= 0) { commit(options[activeIndex].value); } break; case "Escape": event.preventDefault(); setOpen(false); break; default: break; } } // 훅을 전부 부른 뒤에 빠져나간다 — 위쪽에서 반환하면 훅 호출 순서가 깨진다. if (hidden) { return null; } return (
onHoverChange(true) : undefined} onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined} > {label ? ( {label} ) : null}
{open ? ( {options.map((option, index) => ( setActiveIndex(index)} onSelect={() => commit(option.value)} /> ))} ) : null}
{hint ? (
{hintIcon ? ( ) : null} {hint}
) : null}
); } // 시안 아이콘. viewBox가 24×24 정사각형이고 화살표가 그 안에 여백을 두고 들어 있다 — // 여백이 SVG에 내장돼 있어 슬롯이 커지고 작아질 때 여백도 같이 비례한다(CSS 패딩 불필요). // `fill`은 시안의 리터럴 대신 `currentColor`로 두어 색을 토큰이 정한다. function ChevronIcon({ className }: { className?: string }) { return