"use client"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode, type Ref, } from "react"; import { cx } from "../../utils"; import type { FoxSelectItem } from "../fox-select"; import { FoxSelectOption } from "../fox-select-option"; import { FoxSelectOptionItem } from "../fox-select-option-item"; export type FoxSelectTextSize = "lg" | "md" | "sm"; export interface FoxSelectTextProps { size: FoxSelectTextSize; /** `FoxSelect`와 같은 데이터 모양이다 — 열리는 목록이 같은 조각이므로 타입도 같이 쓴다. */ options: FoxSelectItem[]; /** 주면 제어 컴포넌트가 된다 — 호출부가 소유하고, 이 요소가 스스로 바꾸지 않는다. */ value?: string; /** 비제어로 쓸 때의 초기 값. `value`를 주면 무시된다. */ defaultValue?: string; /** 값이 바뀔 때 호출된다(`FoxSelect.onValueChange`와 같은 이름·같은 근거). */ onValueChange?: (value: string) => void; /** 아무 옵션도 선택되지 않았을 때 보여줄 문구. */ placeholder?: ReactNode; disabled?: boolean; /** * 글자를 대신할 읽어 줄 이름. * * 폼 라벨이 없는 컴포넌트라 보이는 글자가 곧 이름이다. 그런데 그 글자가 고른 값 * ("2026년 8월")이라 무엇을 고르는 자리인지는 소리로 전달되지 않는다 — `"조회 기간"`처럼 * 적어 준다. */ /** * 오른쪽 표시를 바꾼다. 기본은 펼침을 뜻하는 캐럿이고, 시안이 다른 뜻을 담을 때만 준다 * (예: 정렬 셀렉트의 위아래 화살표). 장식이라 `aria-hidden`인 자리다. */ icon?: ReactNode; ariaLabel?: string; /** 이름 역할을 하는 요소의 id. `ariaLabel`보다 우선한다. */ labelledBy?: string; id?: string; /** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */ className?: string; /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ hidden?: boolean; /** 트리거를 가리킨다 — 호출부가 포커스를 옮길 때 쓴다. */ ref?: Ref; } const SIZE_CLASS: Record = { lg: "fox-select-text--lg", md: "fox-select-text--md", sm: "fox-select-text--sm", }; /** * @fox 글자만 있는 셀렉트. 테두리도 고정 높이도 없고, 고른 값이 그대로 글자로 보인다. * * `FoxSelect`와 같은 콤보박스지만 **폼 한 칸이 아니다** — 라벨·힌트·에러·필수 표시가 없고, * lg는 글자가 heading/md(제목용 20px bold)다. 화면의 제목이나 목록 위 정렬 기준처럼 "지금 * 무엇을 보고 있는지"를 글자로 보여 주면서 그 자리에서 바꾸게 하는 자리에 쓴다 * (예: `2026년 8월 ∨`, `최신순 ∨`). 값을 입력받아 서버로 보내는 자리라면 `FoxSelect`다. * * 열리는 목록은 `FoxSelect`와 같은 `FoxSelectOption`·`FoxSelectOptionItem`을 쓴다 — 트리거 * 모양만 다르고 목록은 같아야 한다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxSelectText({ size, options, value, defaultValue, onValueChange, placeholder = "선택해주세요", disabled = false, icon, ariaLabel, labelledBy, id, className, hidden = false, ref, }: FoxSelectTextProps) { 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`; // 값이 아니라 순번으로 만든다 — 옵션 값에 공백·특수문자가 들어와도 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이 이미 처리한다 — 여기서 닫으면 // 옵션을 누르는 순간에도 닫혀 클릭이 성사되지 않는다(`FoxSelect`와 같은 근거). 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 handleKeyDown(event: KeyboardEvent) { if (disabled) return; if (!open) { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); openList(); } // Enter·Space는 막지 않는다 — 네이티브 버튼의 클릭이 그대로 열어 준다. 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 ( // `FoxSelectOption`은 `position: absolute`라 위치 기준이 되는 조상이 필요하다 // (`fox-select__control`과 같은 역할).
{open ? ( {options.map((option, index) => ( setActiveIndex(index)} onSelect={() => commit(option.value)} /> ))} ) : null}
); } // 시안 아이콘. `FoxChip`의 것과 같은 글리프다 — 이 패키지는 컴포넌트마다 자기 글리프를 // 안에 두고(`FoxTag`·`FoxSelect`도 그렇다), 아이콘 모듈을 따로 만들지 않는다. // `fill`은 시안의 리터럴 대신 `currentColor`로 두어 색을 크기 규칙이 정하게 한다. // `width`·`height`를 쓰지 않는다 — 크기는 슬롯이 정하고 SVG는 100%로 따라온다. function ChevronMark() { return