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 {
FoxChipSelectOption,
type FoxChipItem,
type FoxChipSelectionMode,
} from "../fox-chip-select-option";
export type FoxChipType = "check" | "select" | "action";
export type FoxChipSize = "lg" | "md";
// 목록이 소유하는 타입이라 그쪽에 두고 여기서 다시 내보낸다 — 호출부는 칩만 알면 된다.
export type { FoxChipItem, FoxChipSelectionMode };
export interface FoxChipProps {
/**
* 아이콘의 유무와 누를 때 벌어지는 일을 정한다.
*
* - `check`: 켜고 끄는 조각. **켜졌을 때만** 글자 앞에 체크 아이콘이 붙는다.
* - `select`: 눌러서 목록을 여는 조각. 글자 뒤에 아래 화살표가 늘 붙고, 글자 앞
* 아이콘은 `icon`을 넘긴 때만 나온다. 값을 고르면 켜짐으로 보인다.
* - `action`: 아이콘 없이 켜고 끄는 조각. 상태별 모양은 `check`와 같다.
*/
type?: FoxChipType;
size?: FoxChipSize;
/**
* 조각에 보이는 글자.
*
* `select`·단일 선택에서는 아무것도 고르지 않았을 때의 문구다 — 값을 고르면 그 옵션의
* 라벨이 대신 보인다(고른 것이 무엇인지가 조각 위에 남아야 필터로 쓸 수 있다).
*
* ⚠️ 다중 선택에서는 언제나 이 글자가 그대로 보인다. 고른 것이 여럿이라 하나를 골라
* 보여 줄 수 없고, 개수를 붙이는 형식(`기간 2`)은 시안에 없어 임의로 만들지 않았다.
* 켜짐(남색 채움)이 "무언가 걸려 있다"는 표시를 대신한다. **확인 필요.**
*/
label: ReactNode;
/**
* 보이는 글자를 대신할 읽어 줄 이름. 넘기지 않으면 글자가 그대로 이름이 된다.
*
* 조각 여러 개가 같은 글자를 쓰는 자리에 적어 준다 — 예를 들어 기간·지역 필터가 나란히
* 있고 둘 다 "전체"라면, 소리로는 구분되지 않는다.
*/
ariaLabel?: string;
disabled?: boolean;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
// ── check·action 전용 ────────────────────────────────────────────────────────
/** 주면 제어 컴포넌트가 된다 — 켜짐 여부를 호출부가 소유하고 조각이 스스로 바꾸지 않는다. */
checked?: boolean;
/** 비제어로 쓸 때의 초기 켜짐 여부. `checked`를 주면 무시된다. */
defaultChecked?: boolean;
/**
* 켜지거나 꺼질 때 호출된다.
*
* ⚠️ 이름이 `onChange`가 아니다. 이 패키지의 기준은 "네이티브 요소를 감싸면 `onChange`,
* 감싸는 요소가 없는 커스텀 위젯이면 `on…Change`"인데, 이 조각은 네이티브
* `<input type="checkbox">`가 아니라 `aria-pressed`를 든 버튼이라 뒤쪽이다
* (`FoxSelect.onValueChange`와 같은 이유).
*/
onCheckedChange?: (checked: boolean) => void;
// ── select 전용 ─────────────────────────────────────────────────────────────
/** 글자 앞에 붙는 아이콘. 넘기지 않으면 자리를 만들지 않는다. `select`에서만 쓴다. */
icon?: ReactNode;
/** 열었을 때 보여 줄 목록. 비어 있으면 목록을 열지 않는다. */
options?: FoxChipItem[];
/**
* 하나만 고를지 여러 개를 고를지 정한다. 값 prop이 계열마다 다르다 —
* `single`은 `value`·`defaultValue`·`onValueChange`(문자열), `multiple`은
* `values`·`defaultValues`·`onValuesChange`(문자열 배열)를 쓴다.
*
* 목록 항목의 모양도 여기서 갈린다(단일은 라디오, 다중은 체크박스). 고른 뒤 닫히는
* 규칙도 마찬가지다 — 컴포넌트 주석의 "닫히는 규칙" 참고.
*/
selectionMode?: FoxChipSelectionMode;
/** 단일 선택에서만 쓴다. 주면 제어 컴포넌트가 된다 — 고른 값을 호출부가 소유한다. */
value?: string;
/** 단일 선택에서만 쓴다. 비제어일 때의 초기 값. `value`를 주면 무시된다. */
defaultValue?: string;
/** 단일 선택에서만 쓴다. 값이 바뀔 때 호출된다. */
onValueChange?: (value: string) => void;
/** 다중 선택에서만 쓴다. 주면 제어 컴포넌트가 된다. */
values?: string[];
/** 다중 선택에서만 쓴다. 비제어일 때의 초기 값들. `values`를 주면 무시된다. */
defaultValues?: string[];
/** 다중 선택에서만 쓴다. 켜고 끌 때마다 **바뀐 뒤의 전체 목록**이 넘어온다. */
onValuesChange?: (values: string[]) => void;
/** 모바일에만 보이는 확인 버튼의 글자. */
confirmLabel?: string;
id?: string;
/** 배치 조정용. 모양이 달라야 하면 여기 말고 `type`이나 모디파이어를 추가한다. */
className?: string;
/** 조각(버튼)을 가리킨다 — 호출부가 포커스를 옮길 때 쓴다. */
ref?: Ref<HTMLButtonElement>;
}
/** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */
const TYPE_CLASS: Record<FoxChipType, string> = {
check: "fox-chip--check",
select: "fox-chip--select",
action: "fox-chip--action",
};
const SIZE_CLASS: Record<FoxChipSize, string> = {
lg: "fox-chip--lg",
md: "fox-chip--md",
};
/**
* @fox 칩. 알약보다 각진 작은 조각이고 **켜짐 여부를 갖는다** — 이것이 `FoxTag`와의 차이다.
* 태그는 눌러서 사라지거나 어디로 넘어가는 조각이라 상태가 남지 않고, 칩은 누른 결과가
* 자기 자신에게 남는다.
*
* 켜짐은 계열에 따라 다른 것에서 온다: `check`·`action`은 눌러서 직접 켜고 끄고,
* `select`는 값을 고르면 켜진 것으로 보인다. 어느 쪽이든 모양 규칙은 한 벌이다.
*
* `check`·`action`은 `aria-pressed`를 든 버튼이다 — 체크박스가 아니다. 폼으로 값을
* 보내야 하면 호출부가 `<input type="hidden">`을 함께 놓는다(칩은 여러 개가 한 묶음으로
* 하나의 값을 이루는 자리에 쓰여서, 이름 한 개를 칩 하나에 묶을 수 없다).
*
* `select`는 `FoxSelect`와 달리 **콤보박스가 아니다.** 열리는 `FoxChipSelectOption`이
* listbox가 아니라 체크박스·라디오 목록이라, 칩은 그것을 여닫는 버튼(`aria-expanded`)일
* 뿐이고 항목 이동은 네이티브 입력이 알아서 한다. `aria-activedescendant`로 가상 커서를
* 옮기던 규칙이 여기에는 없다.
*
* ── 닫히는 규칙 (`select`) ───────────────────────────────────────────────────
*
* `selectionMode`가 정한다. 데스크톱 기준이다.
*
* - `single`: 하나를 고르면 **그 자리에서 반영되고 목록이 닫힌다.** 더 고를 것이 없으니
* 닫지 않을 이유가 없다.
* - `multiple`: 켜고 꺼도 **목록이 열린 채로 남는다.** 여러 개를 연달아 고르는 것이 목적이라
* 하나 누를 때마다 닫히면 쓸 수 없다. 닫는 것은 **바깥을 누르는 것**이고(Escape·Tab으로
* 빠져나가는 것도 같다), 그때까지의 체크는 이미 `onValuesChange`로 반영돼 있다 — 닫기가
* 곧 확정이 아니라서 취소 개념이 없다.
*
* 모바일에는 목록 맨 아래에 확인 버튼이 붙는다(시안 주석 "모바일만 노출"). 그 버튼은 값을
* 확정하는 것이 아니라 **닫기만 한다** — 위와 같은 이유로 이미 반영돼 있기 때문이다.
* 데스크톱에서는 CSS가 그 버튼을 숨긴다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxChip({
type = "check",
size = "lg",
label,
ariaLabel,
disabled = false,
hidden = false,
checked,
defaultChecked = false,
onCheckedChange,
icon,
options,
selectionMode = "single",
value,
defaultValue,
onValueChange,
values,
defaultValues,
onValuesChange,
confirmLabel,
id,
className,
ref,
}: FoxChipProps) {
const [uncontrolledChecked, setUncontrolledChecked] = useState(defaultChecked);
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
const [uncontrolledValues, setUncontrolledValues] = useState<string[]>(defaultValues ?? []);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const isSelect = type === "select";
const isMultiple = selectionMode === "multiple";
const items = options ?? [];
const currentValue = value ?? uncontrolledValue;
const currentValues = values ?? uncontrolledValues;
// 두 계열의 상태를 한 모양(배열)으로 모아 목록에 넘긴다 — 목록은 계열마다 다른 prop을
// 알 필요가 없다.
const selectedValues = isMultiple
? currentValues
: currentValue
? [currentValue]
: [];
const selectedItem = !isMultiple
? items.find((item) => item.value === currentValue)
: undefined;
const generatedId = useId();
const chipId = id ?? generatedId;
const listId = `${chipId}-list`;
// 켜짐의 출처가 계열마다 다르다. select는 고른 값이 하나라도 있으면 켜진 것이고(다중에서도
// 개수와 무관하게 켜짐/꺼짐 둘뿐이다), 나머지는 호출부가 준 `checked`(없으면 내부 상태)다.
const isChecked = isSelect ? selectedValues.length > 0 : (checked ?? uncontrolledChecked);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
// 포인터만 보면 Tab으로 빠져나갈 때 목록이 열린 채 남는다. 포커스가 아무 데도 가지
// 않은 경우(relatedTarget이 null)는 위 pointerdown이 이미 처리한다.
//
// 목록 안의 체크박스·라디오로 포커스가 옮겨 가는 것은 닫기가 아니다 — 그것들도
// `rootRef` 안이라 아래 조건에 걸리지 않는다.
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 toggle() {
const next = !isChecked;
if (checked === undefined) setUncontrolledChecked(next);
onCheckedChange?.(next);
}
/**
* 항목 하나를 고른다. 닫기까지 여기서 갈린다 — 컴포넌트 주석의 "닫히는 규칙" 참고.
*
* 제어 컴포넌트일 때는 내부 값을 건드리지 않는다 — 건드리면 호출부가 값을 거부해도
* 내부에만 반영되어 둘이 조용히 갈라진다.
*/
function select(next: string) {
if (isMultiple) {
// 이미 켜져 있으면 끈다. 순서는 `options`가 정한 순서를 따라가게 다시 추린다 —
// 누른 순서대로 쌓으면 같은 조합인데도 배열이 달라져 비교·저장이 흔들린다.
const nextValues = currentValues.includes(next)
? currentValues.filter((item) => item !== next)
: items
.filter((item) => item.value === next || currentValues.includes(item.value))
.map((item) => item.value);
if (values === undefined) setUncontrolledValues(nextValues);
onValuesChange?.(nextValues);
// 목록을 닫지 않는다. 여러 개를 연달아 고르는 것이 목적이라, 하나 누를 때마다 닫히면
// 쓸 수 없다. 닫는 것은 바깥 클릭·Escape·Tab이고 그건 위 effect와 키 처리가 맡는다.
return;
}
if (value === undefined) setUncontrolledValue(next);
onValueChange?.(next);
// 단일 선택은 고르는 순간 할 일이 끝나므로 닫는다.
close();
}
function close() {
setOpen(false);
// 목록 안에 포커스가 있었다면 갈 곳이 사라진다 — 여는 버튼으로 돌려놓는다.
triggerRef.current?.focus();
}
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (disabled || !isSelect) return;
if (!open) {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
}
// Enter·Space는 막지 않는다 — 네이티브 버튼의 클릭이 그대로 열어 준다.
return;
}
if (event.key === "Escape") {
event.preventDefault();
close();
}
}
// 훅을 전부 부른 뒤에 빠져나간다 — 위쪽에서 반환하면 훅 호출 순서가 깨진다.
if (hidden) {
return null;
}
// 호출부 ref와 내부 ref를 함께 채운다 — 목록을 닫을 때 포커스를 돌려놓으려면 안에서도
// 버튼을 쥐어야 한다.
const attachTrigger = (node: HTMLButtonElement | null) => {
triggerRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as { current: HTMLButtonElement | null }).current = node;
}
};
const chip = (
<button
ref={isSelect ? attachTrigger : ref}
type="button"
id={chipId}
disabled={disabled}
aria-label={ariaLabel}
// select는 목록을 여닫는 버튼이다 — listbox가 아니라 체크박스·라디오 묶음을 열므로
// `role="combobox"`가 아니라 그냥 버튼 + `aria-expanded`다.
aria-expanded={isSelect ? open : undefined}
// 목록은 열렸을 때만 렌더되므로, 닫힌 동안 없는 id를 가리키지 않게 한다.
aria-controls={isSelect && open ? listId : undefined}
aria-pressed={isSelect ? undefined : isChecked}
className={cx(
"fox-chip",
TYPE_CLASS[type],
SIZE_CLASS[size],
isChecked && "fox-chip--checked",
// 목록을 감싸는 자리가 따로 있어서, select는 바깥 div가 className을 받는다.
!isSelect && className
)}
onClick={isSelect ? () => setOpen((current) => !current) : toggle}
onKeyDown={handleKeyDown}
>
{/* 앞 아이콘. check는 켜졌을 때만 체크를, select는 호출부가 준 것을 놓는다. */}
{type === "check" && isChecked ? (
<span className="fox-chip__icon" aria-hidden="true">
<CheckMark />
</span>
) : null}
{isSelect && icon ? (
<span className="fox-chip__icon" aria-hidden="true">
{icon}
</span>
) : null}
<span className="fox-chip__label">{selectedItem?.label ?? label}</span>
{isSelect ? (
<span className="fox-chip__icon fox-chip__arrow" aria-hidden="true">
<ChevronMark />
</span>
) : null}
</button>
);
if (!isSelect) {
return chip;
}
// `FoxChipSelectOption`은 `position: absolute`라 위치 기준이 되는 조상이 필요하다.
return (
<div ref={rootRef} className={cx("fox-chip-select", className)}>
{chip}
{open && items.length > 0 ? (
<FoxChipSelectOption
id={listId}
labelledBy={chipId}
// 칩의 크기를 그대로 물려준다 — 크기 이름이 같고, 항목의 글자 크기도 칩과 같은
// 값으로 떨어진다(lg 17px, md 15px).
size={size}
selectionMode={selectionMode}
options={items}
values={selectedValues}
onSelect={select}
confirmLabel={confirmLabel}
onConfirm={close}
/>
) : null}
</div>
);
}
// 시안 아이콘 둘. `fill`을 시안의 리터럴(흰색·#6D7882) 대신 `currentColor`로 두면 색을
// 상태 규칙이 정한다 — 남색 배경 위에서 흰색으로, 비활성에서 흐린 회색으로 따라간다.
// `width`·`height`를 쓰지 않는다 — 크기는 슬롯이 정하고 SVG는 100%로 따라온다.
// check 계열의 체크 표시.
function CheckMark() {
return <i className="fox-ico fox-ico--bold fox-ico-Check" aria-hidden="true" />;
}
// select 계열의 아래 화살표.
function ChevronMark() {
return <i className="fox-ico fox-ico-CaretDown" aria-hidden="true" />;
}