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,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
import { cx } from "../../utils";
import type { FoxBulletLevel, FoxBulletListItemProps } from "../fox-bullet-list-item";
export interface FoxBulletListProps {
/** `FoxBulletListItem`들. 단계는 평평하게 늘어놓고 각 항목의 `level`로 적는다. */
children: ReactNode;
/** 참이면 번호 목록(`1.` · `a.` · `①`)이 되고, 아니면 기호 목록이다. */
ordered?: boolean;
/** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
hidden?: boolean;
/** 배치 조정용. */
className?: string;
}
type ItemElement = ReactElement<FoxBulletListItemProps>;
type Entry = { level: FoxBulletLevel; element: ItemElement };
/** Fragment로 감싸 넘겨도 안쪽 항목까지 찾아낸다. */
function collect(children: ReactNode): Entry[] {
const entries: Entry[] = [];
Children.forEach(children, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment) {
const fragment = child as ReactElement<{ children?: ReactNode }>;
entries.push(...collect(fragment.props.children));
return;
}
const element = child as ItemElement;
entries.push({ level: element.props.level ?? 1, element });
});
return entries;
}
/**
* 평평한 단계 목록을 진짜 중첩으로 접는다.
*
* 더 깊은 항목들은 바로 앞 항목 **안쪽** 목록이 된다 — `<ul>`의 자식은 `<li>`뿐이라
* 중첩 목록을 형제로 둘 수 없다. 앞에 걸어 둘 항목이 없으면(첫 줄부터 2단계로 시작하는
* 경우) 빈 `<li>`에 매달아 유효한 목록을 지킨다.
*/
function build(entries: Entry[], depth: FoxBulletLevel, ordered: boolean): ReactNode[] {
const nodes: ReactNode[] = [];
let ordinal = 0;
let index = 0;
while (index < entries.length) {
let next = index;
if (entries[index].level > depth) {
while (next < entries.length && entries[next].level > depth) {
next += 1;
}
nodes.push(
<li className="fox-bullet-list__hanger" key={`hanger-${index}`}>
{sublist(entries.slice(index, next), depth, ordered)}
</li>
);
index = next;
continue;
}
next = index + 1;
while (next < entries.length && entries[next].level > depth) {
next += 1;
}
const deeper = entries.slice(index + 1, next);
ordinal += 1;
nodes.push(
cloneElement(entries[index].element, {
key: `item-${index}`,
level: depth,
ordered,
ordinal,
nested: deeper.length > 0 ? sublist(deeper, depth, ordered) : undefined,
})
);
index = next;
}
return nodes;
}
function sublist(entries: Entry[], depth: FoxBulletLevel, ordered: boolean): ReactNode {
const Tag = ordered ? "ol" : "ul";
const deeper = Math.min(depth + 1, 3) as FoxBulletLevel;
return (
<Tag className="fox-bullet-list fox-bullet-list--nested">{build(entries, deeper, ordered)}</Tag>
);
}
/**
* @fox 글머리 목록. 항목의 `level`만 보고 중첩과 번호를 스스로 만든다.
*
* 호출부는 단계를 평평하게 늘어놓기만 하면 된다 — 어느 항목 안에 무엇을 넣을지 따질
* 필요가 없다. 나가는 마크업은 `<ul>`/`<ol>` 안에 `<ul>`/`<ol>`이 든 진짜 중첩이라
* 스크린리더가 몇 단계인지 알린다.
*
* ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
* (또는 개별 파티셜)로 한 번 불러와야 한다.
*/
export function FoxBulletList({
children,
ordered = false,
hidden = false,
className,
}: FoxBulletListProps) {
if (hidden) {
return null;
}
const entries = collect(children);
if (entries.length === 0) {
return null;
}
const Tag = ordered ? "ol" : "ul";
return (
<Tag className={cx("fox-bullet-list", className)}>{build(entries, 1, ordered)}</Tag>
);
}