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 {
Fragment,
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";
export type FoxSelectSelectionMode = "single" | "multiple";
/** 옵션 하나의 데이터. 그리는 일은 `FoxSelectOptionItem`이 한다. */
export interface FoxSelectItem {
value: string;
label: ReactNode;
/** 옵션 라벨 뒤에 붙는 아이콘. 없으면 텍스트만 보여준다. */
icon?: ReactNode;
/** 위험한 선택지(시안의 `state=danger`). 글자·아이콘이 danger 색으로 바뀐다. */
danger?: boolean;
}
export interface FoxSelectProps {
size: FoxSelectSize;
options: FoxSelectItem[];
/**
* 하나만 고를지 여러 개를 고를지 정한다. 값 prop이 계열마다 다르다 —
* `single`은 `value`·`defaultValue`·`onValueChange`(문자열), `multiple`은
* `values`·`defaultValues`·`onValuesChange`(문자열 배열)를 쓴다.
*
* `multiple`이면 트리거에 고른 옵션의 라벨이 **쉼표로 이어져** 보이고, 하나를 골라도
* 목록이 닫히지 않는다 — 여러 개를 연달아 고르는 것이 목적이라 누를 때마다 닫히면 쓸 수
* 없다. 닫는 것은 바깥 클릭·Escape·Tab이다(`FoxChip`의 select와 같은 규칙).
*/
selectionMode?: FoxSelectSelectionMode;
/** 단일 선택에서만 쓴다. 주면 제어 컴포넌트가 된다 — 호출부가 소유하고, select가 스스로 바꾸지 않는다. */
value?: string;
/**
* 비제어로 쓸 때의 초기 값. `value`를 주면 무시된다.
*
* 버튼 계열과 달리 값 상태를 안에 둘 수 있게 열어 둔 이유는, 폼 한 칸을 놓기 위해
* 호출부마다 `useState`를 반복하는 비용이 크기 때문이다(사용자 승인).
*/
defaultValue?: string;
/**
* 값이 바뀔 때 호출된다.
*
* ⚠️ 이름을 `onChange`로 바꾸지 않는다. 이 패키지의 기준은 "네이티브 요소를 감싸면
* `onChange`(그 네이티브 것을 `Omit`으로 걷어내고 대신한다), 감싸는 요소가 없는 커스텀
* 위젯이면 `onValueChange`"다. FoxInput·FoxTextArea·FoxRadio는 앞쪽이고, 이 컴포넌트는
* 네이티브 `<select>`가 아니라 버튼 + 리스트박스라 뒤쪽이다.
*
* 게다가 `FoxEmail`·`FoxPhoneNumber`가 이 이름으로 호출하고 있어 바꾸면 함께 깨진다.
*/
onValueChange?: (value: string) => void;
/** 다중 선택에서만 쓴다. 주면 제어 컴포넌트가 된다. */
values?: string[];
/** 다중 선택에서만 쓴다. 비제어일 때의 초기 값들. `values`를 주면 무시된다. */
defaultValues?: string[];
/** 다중 선택에서만 쓴다. 켜고 끌 때마다 **바뀐 뒤의 전체 목록**이 넘어온다. */
onValuesChange?: (values: string[]) => void;
/** 아무 옵션도 선택되지 않았을 때 트리거에 보여줄 문구. */
placeholder?: ReactNode;
/** Figma 시안의 error 상태. */
error?: boolean;
disabled?: boolean;
/**
* Figma 시안의 view(조회 전용) 상태. 값은 보이되 바꿀 수 없다 — 내부적으로는
* `disabled`를 걸되, disabled(입력 불가 안내)와 시각적으로 구분해 보여준다.
*/
readOnly?: boolean;
/** 필드 위 라벨. 넘기지 않으면 라벨을 렌더링하지 않는다. */
label?: ReactNode;
/**
* 라벨을 두지 않는 자리(시안의 필터 셀렉트 등)에서 트리거를 읽어 줄 이름.
* `label`이 있으면 그쪽이 이름이라 무시된다(`FoxSelectText`와 같은 규칙).
*/
ariaLabel?: string;
/** 라벨 뒤 필수·선택 표시. `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,
selectionMode = "single",
value,
defaultValue,
onValueChange,
values,
defaultValues,
onValuesChange,
placeholder = "선택해주세요",
error,
disabled = false,
readOnly = false,
label,
ariaLabel,
requirement,
hint,
hintIcon,
id,
className,
hidden = false,
onHoverChange,
ref,
}: FoxSelectProps) {
const isDisabled = disabled || readOnly;
const isMultiple = selectionMode === "multiple";
const [open, setOpen] = useState(false);
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
const [uncontrolledValues, setUncontrolledValues] = useState<string[]>(defaultValues ?? []);
const [activeIndex, setActiveIndex] = useState(-1);
const rootRef = useRef<HTMLDivElement>(null);
const currentValue = value ?? uncontrolledValue;
const currentValues = values ?? uncontrolledValues;
const isSelected = (optionValue: string) =>
isMultiple ? currentValues.includes(optionValue) : optionValue === currentValue;
// 화살표가 어디서 시작할지 정한다. 다중 선택에서는 켜진 것 중 첫 번째다.
const selectedIndex = options.findIndex((option) => isSelected(option.value));
const selectedOptions = options.filter((option) => isSelected(option.value));
// 트리거에 보이는 것. 다중이면 고른 라벨을 쉼표로 잇는다 — 라벨이 ReactNode일 수 있어
// `join`을 쓰지 못하므로 사이에 구분자를 끼워 넣는다.
const displayValue =
selectedOptions.length === 0 ? (
placeholder
) : isMultiple ? (
selectedOptions.map((option, index) => (
<Fragment key={option.value}>
{index > 0 ? ", " : null}
{option.label}
</Fragment>
))
) : (
selectedOptions[0].label
);
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 (isMultiple) {
// 이미 켜져 있으면 끈다. 순서는 `options`가 정한 순서를 따라가게 다시 추린다 —
// 누른 순서대로 쌓으면 같은 조합인데도 배열이 달라져 비교·저장이 흔들리고, 트리거에
// 이어 붙는 글자 순서도 목록과 어긋난다.
const nextValues = currentValues.includes(next)
? currentValues.filter((item) => item !== next)
: options
.filter((option) => option.value === next || currentValues.includes(option.value))
.map((option) => option.value);
if (values === undefined) setUncontrolledValues(nextValues);
onValuesChange?.(nextValues);
// 목록을 닫지 않는다 — 여러 개를 연달아 고르는 것이 목적이다.
return;
}
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-label={label ? undefined : ariaLabel}
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",
selectedOptions.length > 0 && "fox-select__trigger--completed"
)}
onClick={() => (open ? setOpen(false) : openList())}
onKeyDown={handleTriggerKeyDown}
>
<span className="fox-select__value">{displayValue}</span>
<ChevronIcon className="fox-select__icon" />
</button>
{open ? (
<FoxSelectOption
id={listboxId}
labelledBy={label ? labelId : undefined}
multiselectable={isMultiple}
>
{options.map((option, index) => (
<FoxSelectOptionItem
key={option.value}
id={optionId(index)}
label={option.label}
icon={option.icon}
danger={option.danger}
selected={isSelected(option.value)}
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 <i className={`fox-ico fox-ico-CaretDown ${className ?? ""}`.trim()} aria-hidden="true" />;
}