File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
08-14
08-14
08-18
08-14
08-18
08-18
File name
Commit message
Commit date
"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<HTMLButtonElement>;
}
const SIZE_CLASS: Record<FoxSelectTextSize, string> = {
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<HTMLDivElement>(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<HTMLButtonElement>) {
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`과 같은 역할).
<div ref={rootRef} className={cx("fox-select-text", SIZE_CLASS[size], className)}>
<button
ref={ref}
type="button"
id={triggerId}
role="combobox"
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
// 목록은 열렸을 때만 렌더되므로, 닫힌 동안 없는 id를 가리키지 않게 한다.
aria-controls={open ? listboxId : undefined}
// 화살표로 옮겨 다니는 옵션을 보조기술에 알린다. 이게 없으면 `--active` 강조가
// 시각 전용이라 스크린리더 사용자는 이동을 알 수 없다.
aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
aria-label={labelledBy ? undefined : ariaLabel}
aria-labelledby={labelledBy}
className="fox-select-text__trigger"
onClick={() => (open ? setOpen(false) : openList())}
onKeyDown={handleKeyDown}
>
<span className="fox-select-text__value">{selectedOption?.label ?? placeholder}</span>
<span className="fox-select-text__icon" aria-hidden="true">
{icon ?? <ChevronMark />}
</span>
</button>
{open ? (
<FoxSelectOption
id={listboxId}
labelledBy={labelledBy}
className="fox-select-text__list"
>
{options.map((option, index) => (
<FoxSelectOptionItem
key={option.value}
id={optionId(index)}
label={option.label}
icon={option.icon}
danger={option.danger}
selected={option.value === currentValue}
active={index === activeIndex}
onHover={() => setActiveIndex(index)}
onSelect={() => commit(option.value)}
/>
))}
</FoxSelectOption>
) : null}
</div>
);
}
// 시안 아이콘. `FoxChip`의 것과 같은 글리프다 — 이 패키지는 컴포넌트마다 자기 글리프를
// 안에 두고(`FoxTag`·`FoxSelect`도 그렇다), 아이콘 모듈을 따로 만들지 않는다.
// `fill`은 시안의 리터럴 대신 `currentColor`로 두어 색을 크기 규칙이 정하게 한다.
// `width`·`height`를 쓰지 않는다 — 크기는 슬롯이 정하고 SVG는 100%로 따라온다.
function ChevronMark() {
return <i className="fox-ico fox-ico--bold fox-ico-CaretDown" aria-hidden="true" />;
}