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 {
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 (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M14.5306 5.03057L6.5306 13.0306C6.46092 13.1005 6.37813 13.156 6.28696 13.1938C6.1958 13.2317 6.09806 13.2512 5.99935 13.2512C5.90064 13.2512 5.8029 13.2317 5.71173 13.1938C5.62057 13.156 5.53778 13.1005 5.4681 13.0306L1.9681 9.53057C1.89833 9.4608 1.84299 9.37798 1.80524 9.28683C1.76748 9.19568 1.74805 9.09798 1.74805 8.99932C1.74805 8.90066 1.76748 8.80296 1.80524 8.71181C1.84299 8.62066 1.89833 8.53783 1.9681 8.46807C2.03786 8.3983 2.12069 8.34296 2.21184 8.30521C2.30299 8.26745 2.40069 8.24802 2.49935 8.24802C2.59801 8.24802 2.69571 8.26745 2.78686 8.30521C2.87801 8.34296 2.96083 8.3983 3.0306 8.46807L5.99997 11.4374L13.4693 3.96932C13.6102 3.82842 13.8013 3.74927 14.0006 3.74927C14.1999 3.74927 14.391 3.82842 14.5318 3.96932C14.6727 4.11021 14.7519 4.30131 14.7519 4.50057C14.7519 4.69983 14.6727 4.89092 14.5318 5.03182L14.5306 5.03057Z"
fill="currentColor"
/>
</svg>
);
}
// select 계열의 아래 화살표.
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>
);
}