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 { useId, type ReactNode, type Ref } from "react";
import { cx } from "../../utils";
import { FoxCheckbox } from "../fox-checkbox";
import { FoxCheckboxGroup } from "../fox-checkbox-group";
import { FoxRadio } from "../fox-radio";
import { FoxRadioGroup } from "../fox-radio-group";
export type FoxChipSelectionMode = "single" | "multiple";
/** 칩 목록의 항목 하나. 그리는 일은 `FoxCheckbox`·`FoxRadio`가 한다. */
export interface FoxChipItem {
value: string;
label: ReactNode;
/** 이 항목만 고를 수 없게 한다. */
disabled?: boolean;
}
export interface FoxChipSelectOptionProps {
/** 목록을 여는 요소의 `aria-controls`가 가리킬 값. */
id?: string;
/** 목록에 이름을 주는 요소의 id — 보통 칩 자신이다. */
labelledBy?: string;
/**
* 하나만 고를지 여러 개를 고를지. 항목의 모양이 여기서 갈린다 —
* `single`은 라디오 목록, `multiple`은 체크박스 목록이다.
*/
selectionMode?: FoxChipSelectionMode;
options: FoxChipItem[];
/**
* 라디오 묶음의 `name`. `single`에서만 쓰이고, 넘기지 않으면 자동으로 만든다.
*
* 같은 화면에 칩 목록이 둘 이상 열릴 일은 없지만, `name`이 겹치면 브라우저가 한 묶음으로
* 보고 서로를 끄기 때문에 자동 생성이라도 반드시 있어야 한다.
*/
name?: string;
/** 지금 켜진 값들. `single`이면 길이가 0 또는 1이다 — 두 계열의 상태를 한 모양으로 받는다. */
values?: string[];
/** 항목을 켜거나 끌 때 호출된다. 무엇을 어떻게 반영할지는 받는 쪽이 정한다. */
onSelect?: (value: string) => void;
/**
* 모바일에만 보이는 확인 버튼의 글자. 버튼 자체는 언제나 렌더되고 **CSS가 데스크톱에서
* 숨긴다** — 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거려서, 열린 채로 화면을
* 돌리면 포커스가 날아간다.
*/
confirmLabel?: string;
/** 확인 버튼을 눌렀을 때. 보통 목록을 닫는다. */
onConfirm?: () => void;
/**
* 항목(체크박스·라디오)의 크기. 여는 칩의 크기를 그대로 받는다 — 두 컴포넌트의 크기
* 이름이 같고 글자 크기도 같은 값이라(lg 17px, md 15px) 그대로 이어진다. 칩만 크고
* 목록은 작으면 같은 필터인데 두 벌처럼 보인다.
*/
size?: "lg" | "md";
/** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */
className?: string;
ref?: Ref<HTMLDivElement>;
}
/**
* @fox 칩의 `select`가 열렸을 때 뜨는 목록. 배치만 책임지고 상태를 갖지 않는다 —
* 열림 여부·고른 값은 전부 목록을 여는 칩이 소유한다.
*
* `FoxSelectOption`과 **다른 컴포넌트다.** 저쪽은 `role="listbox"` + `role="option"`이고
* 항목이 글자 한 줄이지만, 이쪽은 시안이 **체크박스·라디오 목록**이라 이미 있는
* `FoxCheckboxGroup`·`FoxRadioGroup`을 그대로 쓴다. 네이티브 입력이라 단일 선택의
* 화살표 이동·폼 전송을 브라우저가 처리하고, 항목 모양이 다른 화면의 체크박스와 어긋나지
* 않는다.
*
* 항목이 진짜 입력이라 `FoxSelectOption`처럼 `mousedown`을 막지 않는다 — 눌러서 그 입력에
* 포커스가 가는 것이 맞다. 목록이 닫히지 않는 이유는 포커스가 여는 쪽과 **같은 조상 안**에
* 머물러서다(칩의 focusout 처리가 그것을 본다).
*
* ⚠️ 스스로 위치를 잡지 못한다 — `position: absolute`라 위치 기준이 되는 조상이 필요하다.
* `FoxChip` 안에서는 `fox-chip-select`가 그 역할을 한다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxChipSelectOption({
id,
labelledBy,
selectionMode = "single",
options,
name,
values = [],
onSelect,
confirmLabel = "확인",
onConfirm,
size = "md",
className,
ref,
}: FoxChipSelectOptionProps) {
const generatedName = useId();
const groupName = name ?? generatedName;
const isMultiple = selectionMode === "multiple";
return (
<div
ref={ref}
id={id}
// 목록 전체는 그냥 묶음이다. 단일 선택의 `radiogroup` 의미는 안쪽
// `FoxRadioGroup`이 갖는다 — 확인 버튼이 그 묶음 밖에 있어야 하기 때문이다.
role="group"
aria-labelledby={labelledBy}
className={cx("fox-chip-select-option", className)}
>
<div className="fox-chip-select-option__list">
{isMultiple ? (
<FoxCheckboxGroup
orientation="vertical"
labelledBy={labelledBy}
// 항목 사이 간격만 묶음 기본값과 다르다 — 스타일 파일의 같은 이름 규칙 참고.
className="fox-chip-select-option__group"
>
{options.map((option) => (
<FoxCheckbox
key={option.value}
size={size}
value={option.value}
label={option.label}
checked={values.includes(option.value)}
disabled={option.disabled}
onChange={() => onSelect?.(option.value)}
/>
))}
</FoxCheckboxGroup>
) : (
<FoxRadioGroup
orientation="vertical"
name={groupName}
labelledBy={labelledBy}
className="fox-chip-select-option__group"
>
{options.map((option) => (
<FoxRadio
key={option.value}
size={size}
value={option.value}
label={option.label}
checked={values.includes(option.value)}
disabled={option.disabled}
onChange={() => onSelect?.(option.value)}
/>
))}
</FoxRadioGroup>
)}
</div>
{/*
확인 버튼은 **다중 선택에만** 있다. 이 버튼이 하는 일은 "고르기를 마쳤다"고 알려 목록을
닫는 것인데, 단일 선택은 하나 고르는 순간 목록이 닫히므로 버튼이 할 일이 없다 — 남겨
두면 눌러도 아무 일이 없는 버튼이 모바일 화면 아래를 차지한다.
값이 반영되는 시점은 플랫폼과 무관하게 같다(고르는 즉시). 모바일에서 달라지는 것은
**닫는 수단이 하나 더 생기는 것**뿐이고, 데스크톱에서는 CSS가 이 버튼을 숨긴다.
*/}
{isMultiple ? (
<button
type="button"
className="fox-chip-select-option__confirm"
onClick={onConfirm}
>
{confirmLabel}
</button>
) : null}
</div>
);
}