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 {
Children,
Fragment,
cloneElement,
createRef,
isValidElement,
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import { cx } from "../../utils";
import type { FoxTabItemProps } from "../fox-tab-item";
export type FoxTabType = "line" | "fill";
export type FoxTabSize = "lg" | "md";
export interface FoxTabProps {
/** `FoxTabItem`들. */
children?: ReactNode;
/** `line`은 밑줄로, `fill`은 색을 채워 고른 탭을 표시한다. */
type?: FoxTabType;
/** `lg`는 폭을 고르게 나눠 가지고, `md`는 글자만큼만 차지하고 나란히 선다. */
size?: FoxTabSize;
/** 고른 탭의 `value`. */
value?: string;
onValueChange?: (value: string) => void;
/** 목록을 읽어 줄 이름. */
label?: string;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
}
type ItemElement = ReactElement<FoxTabItemProps>;
/** 프래그먼트 안까지 훑어 탭을 모은다. */
function collect(children: ReactNode): ItemElement[] {
const items: ItemElement[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment) {
const fragment = child as ReactElement<{ children?: ReactNode }>;
items.push(...collect(fragment.props.children));
return;
}
items.push(child as ItemElement);
});
return items;
}
/**
* @fox 탭 묶음. 어느 탭이 골라졌는지는 이 컴포넌트가 소유한다.
*
* 항목이 `href`를 하나라도 가지면 주소가 바뀌는 탭으로 보고 탭 역할(`role="tablist"`)을
* 붙이지 않는다 — 같은 화면 안의 전환이 아니라 이동이기 때문이다. 그 밖에는 ARIA 탭
* 규약대로 좌우 화살표·Home·End로 옮길 수 있고 탭키는 고른 탭에만 닿는다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxTab({
children,
type = "line",
size = "lg",
value,
onValueChange,
label = "탭",
hidden = false,
className,
}: FoxTabProps) {
if (hidden) {
return null;
}
const items = collect(children);
const tabRole = !items.some((item) => item.props.href);
const refs = items.map(() => createRef<HTMLElement>());
// 잠긴 탭은 건너뛴다 — 옮겨 가도 고를 수 없어 막다른 길이 된다.
const move = (from: number, step: number) => {
const count = items.length;
for (let hop = 1; hop <= count; hop += 1) {
const next = (from + step * hop + count * count) % count;
if (!items[next].props.disabled) {
refs[next].current?.focus();
return;
}
}
};
const navigate = (index: number) => (event: KeyboardEvent<HTMLElement>) => {
if (!tabRole) {
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
move(index, 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
move(index, -1);
} else if (event.key === "Home") {
event.preventDefault();
move(-1, 1);
} else if (event.key === "End") {
event.preventDefault();
move(0, -1);
}
};
return (
<div className={cx("fox-tab", `fox-tab--${type}`, `fox-tab--${size}`, className)}>
<div
className="fox-tab__list"
role={tabRole ? "tablist" : undefined}
aria-label={tabRole ? label : undefined}
>
{items.map((item, index) =>
cloneElement(item, {
key: item.key ?? item.props.value,
active: item.props.value === value,
tabRole,
onSelect: onValueChange,
onNavigate: navigate(index),
ref: refs[index] as RefObject<HTMLElement>,
})
)}
</div>
</div>
);
}