textmachine/frontend/src/ui/Tabs.tsx

96 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}