"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`"인데, 이 조각은 네이티브 * ``가 아니라 `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; } /** `Record`로 고정해 계열을 추가하면 항목 누락이 타입 에러가 되게 한다. */ const TYPE_CLASS: Record = { check: "fox-chip--check", select: "fox-chip--select", action: "fox-chip--action", }; const SIZE_CLASS: Record = { lg: "fox-chip--lg", md: "fox-chip--md", }; /** * @fox 칩. 알약보다 각진 작은 조각이고 **켜짐 여부를 갖는다** — 이것이 `FoxTag`와의 차이다. * 태그는 눌러서 사라지거나 어디로 넘어가는 조각이라 상태가 남지 않고, 칩은 누른 결과가 * 자기 자신에게 남는다. * * 켜짐은 계열에 따라 다른 것에서 온다: `check`·`action`은 눌러서 직접 켜고 끄고, * `select`는 값을 고르면 켜진 것으로 보인다. 어느 쪽이든 모양 규칙은 한 벌이다. * * `check`·`action`은 `aria-pressed`를 든 버튼이다 — 체크박스가 아니다. 폼으로 값을 * 보내야 하면 호출부가 ``을 함께 놓는다(칩은 여러 개가 한 묶음으로 * 하나의 값을 이루는 자리에 쓰여서, 이름 한 개를 칩 하나에 묶을 수 없다). * * `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(defaultValues ?? []); const [open, setOpen] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(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) { 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 = ( ); if (!isSelect) { return chip; } // `FoxChipSelectOption`은 `position: absolute`라 위치 기준이 되는 조상이 필요하다. return (
{chip} {open && items.length > 0 ? ( ) : null}
); } // 시안 아이콘 둘. `fill`을 시안의 리터럴(흰색·#6D7882) 대신 `currentColor`로 두면 색을 // 상태 규칙이 정한다 — 남색 배경 위에서 흰색으로, 비활성에서 흐린 회색으로 따라간다. // `width`·`height`를 쓰지 않는다 — 크기는 슬롯이 정하고 SVG는 100%로 따라온다. // check 계열의 체크 표시. function CheckMark() { return ( ); } // select 계열의 아래 화살표. function ChevronMark() { return ( ); }