"use client"; import { 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"; /** 옵션 하나의 데이터. 그리는 일은 `FoxSelectOptionItem`이 한다. */ export interface FoxSelectItem { value: string; label: ReactNode; /** 옵션 라벨 뒤에 붙는 아이콘. 없으면 텍스트만 보여준다. */ icon?: ReactNode; /** 위험한 선택지(시안의 `state=danger`). 글자·아이콘이 danger 색으로 바뀐다. */ danger?: boolean; } export interface FoxSelectProps { size: FoxSelectSize; options: FoxSelectItem[]; /** 주면 제어 컴포넌트가 된다 — 호출부가 소유하고, 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, value, defaultValue, onValueChange, placeholder = "선택해주세요", error, disabled = false, readOnly = false, label, requirement, hint, hintIcon, id, className, hidden = false, onHoverChange, ref, }: FoxSelectProps) { const isDisabled = disabled || readOnly; const [open, setOpen] = useState(false); const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? ""); const [activeIndex, setActiveIndex] = useState(-1); const rootRef = useRef(null); const currentValue = value ?? uncontrolledValue; const selectedIndex = options.findIndex((option) => option.value === currentValue); const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined; 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 (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 ( ); }