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-18
08-18
08-14
08-18
08-18
08-18
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월")이라 무엇을 고르는 자리인지는 소리로 전달되지 않는다 — `"조회 기간"`처럼
* 적어 준다.
*/
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,
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">
<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 (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M13.5306 6.53061L8.5306 11.5306C8.46092 11.6005 8.37813 11.656 8.28696 11.6939C8.1958 11.7317 8.09806 11.7512 7.99935 11.7512C7.90064 11.7512 7.8029 11.7317 7.71173 11.6939C7.62057 11.656 7.53778 11.6005 7.4681 11.5306L2.4681 6.53061C2.3272 6.38972 2.24805 6.19862 2.24805 5.99936C2.24805 5.80011 2.3272 5.60901 2.4681 5.46811C2.60899 5.32722 2.80009 5.24806 2.99935 5.24806C3.19861 5.24806 3.3897 5.32722 3.5306 5.46811L7.99997 9.93749L12.4693 5.46749C12.6102 5.32659 12.8013 5.24744 13.0006 5.24744C13.1999 5.24744 13.391 5.32659 13.5318 5.46749C13.6727 5.60838 13.7519 5.79948 13.7519 5.99874C13.7519 6.198 13.6727 6.38909 13.5318 6.52999L13.5306 6.53061Z"
fill="currentColor"
/>
</svg>
);
}