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-14
08-14
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 { 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는 앞쪽이고, 이 컴포넌트는
* 네이티브 `<select>`가 아니라 버튼 + 리스트박스라 뒤쪽이다.
*
* 게다가 `FoxEmail`·`FoxPhoneNumber`가 이 이름으로 호출하고 있어 바꾸면 함께 깨진다.
*/
onValueChange?: (value: string) => void;
/** 아무 옵션도 선택되지 않았을 때 트리거에 보여줄 문구. */
placeholder?: ReactNode;
/** Figma 시안의 error 상태. */
error?: boolean;
disabled?: boolean;
/**
* Figma 시안의 view(조회 전용) 상태. 값은 보이되 바꿀 수 없다 — 내부적으로는
* `disabled`를 걸되, disabled(입력 불가 안내)와 시각적으로 구분해 보여준다.
*/
readOnly?: boolean;
/** 필드 위 라벨. 넘기지 않으면 라벨을 렌더링하지 않는다. */
label?: ReactNode;
/** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다. */
requirement?: FoxFormLabelRequirement;
/** select 아래 힌트 메시지. 넘기지 않으면 힌트 영역을 렌더링하지 않는다. */
hint?: ReactNode;
/** 힌트 메시지 앞에 붙는 아이콘. `hint`가 없으면 의미 없다. */
hintIcon?: ReactNode;
id?: string;
/** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */
className?: string;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 포인터가 올라오면 `true`, 벗어나면 `false`. */
onHoverChange?: (hovered: boolean) => void;
/** 트리거 버튼을 가리킨다 — 호출부가 포커스를 옮길 때 쓴다. */
ref?: Ref<HTMLButtonElement>;
}
const SIZE_CLASS: Record<FoxSelectSize, string> = {
sm: "fox-select--sm",
md: "fox-select--md",
lg: "fox-select--lg",
};
/**
* @fox 커스텀 드롭다운. 네이티브 `<select>`는 옵션 목록 자체를 브라우저가 그려서
* 스타일링할 수 없기 때문에, 버튼(트리거) + 리스트박스로 직접 구현한다.
*
* 버튼 계열과 달리 상태를 갖는다 — 열림 여부와, 비제어로 쓸 때의 값이다. `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<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`;
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<HTMLButtonElement>) {
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 (
<div
ref={rootRef}
className={cx("fox-select", SIZE_CLASS[size], className)}
onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
>
{label ? (
<FoxFormLabel id={labelId} htmlFor={triggerId} requirement={requirement}>
{label}
</FoxFormLabel>
) : null}
<div className="fox-select__control">
<button
ref={ref}
type="button"
id={triggerId}
role="combobox"
disabled={isDisabled}
aria-haspopup="listbox"
aria-expanded={open}
// 리스트박스는 열렸을 때만 렌더되므로, 닫힌 동안 없는 id를 가리키지 않게 한다.
aria-controls={open ? listboxId : undefined}
// 화살표로 옮겨 다니는 옵션을 보조기술에 알린다. 이게 없으면 `--active` 강조가
// 시각 전용이라 스크린리더 사용자는 이동을 알 수 없다.
aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
aria-invalid={error || undefined}
aria-readonly={readOnly || undefined}
className={cx(
"fox-select__trigger",
selectedOption && "fox-select__trigger--completed"
)}
onClick={() => (open ? setOpen(false) : openList())}
onKeyDown={handleTriggerKeyDown}
>
<span className="fox-select__value">{selectedOption?.label ?? placeholder}</span>
<ChevronIcon className="fox-select__icon" />
</button>
{open ? (
<FoxSelectOption id={listboxId} labelledBy={label ? labelId : undefined}>
{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>
{hint ? (
<div className="fox-select__hint">
{hintIcon ? (
<span className="fox-select__hint-icon" aria-hidden="true">
{hintIcon}
</span>
) : null}
<span className="fox-select__hint-message">{hint}</span>
</div>
) : null}
</div>
);
}
// 시안 아이콘. viewBox가 24×24 정사각형이고 화살표가 그 안에 여백을 두고 들어 있다 —
// 여백이 SVG에 내장돼 있어 슬롯이 커지고 작아질 때 여백도 같이 비례한다(CSS 패딩 불필요).
// `fill`은 시안의 리터럴 대신 `currentColor`로 두어 색을 토큰이 정한다.
function ChevronIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M20.296 9.79595L12.796 17.2959C12.6914 17.4008 12.5672 17.484 12.4305 17.5408C12.2938 17.5976 12.1471 17.6268 11.9991 17.6268C11.851 17.6268 11.7044 17.5976 11.5677 17.5408C11.4309 17.484 11.3067 17.4008 11.2022 17.2959L3.70221 9.79595C3.49086 9.5846 3.37213 9.29796 3.37213 8.99907C3.37213 8.70019 3.49086 8.41354 3.70221 8.2022C3.91355 7.99085 4.2002 7.87212 4.49908 7.87212C4.79797 7.87212 5.08461 7.99085 5.29596 8.2022L12 14.9063L18.7041 8.20126C18.9154 7.98992 19.2021 7.87119 19.501 7.87119C19.7998 7.87119 20.0865 7.98992 20.2978 8.20126C20.5092 8.41261 20.6279 8.69925 20.6279 8.99814C20.6279 9.29702 20.5092 9.58367 20.2978 9.79501L20.296 9.79595Z"
fill="currentColor"
/>
</svg>
);
}