96 lines
4.4 KiB
TypeScript
96 lines
4.4 KiB
TypeScript
import { Plus, X } from 'lucide-react';
|
||
import type { ReactNode } from 'react';
|
||
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from 'react-aria-components';
|
||
|
||
import { Button } from './Button';
|
||
import { icon } from './icon';
|
||
import styles from './Tabs.module.css';
|
||
|
||
export interface TabItem {
|
||
id: string;
|
||
label: string;
|
||
content: ReactNode;
|
||
/** Есть обработчик — у вкладки появляется крестик (вкладки-документы центра). */
|
||
onClose?: () => void;
|
||
}
|
||
|
||
export interface TabsProps {
|
||
/** Имя ряда вкладок для скринридера. */
|
||
label: string;
|
||
items: TabItem[];
|
||
selectedId: string;
|
||
onSelect: (id: string) => void;
|
||
/** Вкладка инструмента (серая) или открытого документа (синеватая, как файл во Fleet). */
|
||
look?: 'tool' | 'document';
|
||
/** «+» в конце ряда. Ф-7: кнопка появляется только вместе с действием, декоративной не бывает. */
|
||
add?: { label: string; onPress: () => void };
|
||
/** Что показать, когда вкладок нет вовсе (все документы закрыты). */
|
||
empty?: ReactNode;
|
||
}
|
||
|
||
// Область с вкладками целиком: ряд сверху, содержимое выбранной вкладки под ним. Содержимое —
|
||
// один скролл-контейнер на вкладку; длинные списки внутри скроллятся сами (виртуализация).
|
||
export function Tabs({ label, items, selectedId, onSelect, look = 'tool', add, empty }: TabsProps) {
|
||
return (
|
||
<AriaTabs
|
||
className={styles.tabs}
|
||
selectedKey={selectedId}
|
||
onSelectionChange={(key) => {
|
||
onSelect(String(key));
|
||
}}
|
||
>
|
||
{/* Delete закрывает выбранную вкладку. Обработчик стоит на ряду, а не на самой вкладке:
|
||
клавиатурные события примитив вкладки не принимает, а активация у ряда автоматическая —
|
||
фокус и выбор всегда на одной вкладке. */}
|
||
<div
|
||
className={styles.row}
|
||
data-look={look}
|
||
onKeyDown={(event) => {
|
||
const onTab =
|
||
event.target instanceof HTMLElement && event.target.getAttribute('role') === 'tab';
|
||
if (event.key === 'Delete' && onTab) {
|
||
items.find((item) => item.id === selectedId)?.onClose?.();
|
||
}
|
||
}}
|
||
>
|
||
{/* Пустой ряд не рисуем совсем: tablist без единой вкладки — нарушение доступности,
|
||
и гейт axe в скриншот-цикле роняет на нём сборку. */}
|
||
{items.length > 0 && (
|
||
<TabList className={styles.list} aria-label={label} items={items}>
|
||
{(item) => (
|
||
<Tab className={styles.tab} id={item.id}>
|
||
<span className={styles.title}>{item.label}</span>
|
||
{/* Крестик — не кнопка, и это не небрежность: у роли tab дети презентационные
|
||
(ARIA), поэтому вложенный фокусируемый элемент выпадает из дерева доступности,
|
||
оставаясь в порядке табуляции. Мышь закрывает крестиком, клавиатура — Delete. */}
|
||
{item.onClose && (
|
||
<span
|
||
className={styles.close}
|
||
aria-hidden="true"
|
||
onPointerDown={(event) => {
|
||
event.stopPropagation();
|
||
}}
|
||
onClick={item.onClose}
|
||
>
|
||
<X {...icon} />
|
||
</span>
|
||
)}
|
||
</Tab>
|
||
)}
|
||
</TabList>
|
||
)}
|
||
{add && (
|
||
<Button look="icon" aria-label={add.label} onPress={add.onPress}>
|
||
<Plus {...icon} />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{items.length === 0 && <div className={styles.panel}>{empty}</div>}
|
||
{items.map((item) => (
|
||
<TabPanel className={styles.panel} key={item.id} id={item.id}>
|
||
{item.content}
|
||
</TabPanel>
|
||
))}
|
||
</AriaTabs>
|
||
);
|
||
}
|