textmachine/frontend/src/ui/Tabs.tsx

127 lines
4.5 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;
/** Preview (the VS Code model): the title in italics, the next single click replaces it. */
preview?: boolean;
/** A handler is present — the tab gets a close cross (the document tabs of the centre). */
onClose?: () => void;
}
export interface TabsProps {
/** The name of the row of tabs for the screen reader. */
label: string;
items: TabItem[];
selectedId: string;
onSelect: (id: string) => void;
/** Tool tab, or an open document — the latter is additionally marked along its top edge. */
look?: 'tool' | 'document';
/** A double click on a tab pins it — the VS Code model (remark 2). */
onPin?: (id: string) => void;
/**
* The «+» at the end of the row. Ф-7: a button appears only together with an action, it is never
* decorative.
*/
add?: { label: string; onPress: () => void };
/** What to show when there are no tabs at all (every document closed). */
empty?: ReactNode;
/**
* Panels are not unmounted on a switch (Ф-19). The unselected ones stay in the layout,
* invisible, so the scroll and the position live on, and rebuilding a collection of 2284 nodes
* on every return is not paid for.
*/
keepAlive?: boolean;
}
// The tabbed area as a whole: the row on top, the content of the selected tab under it. The content
// is one box per tab which does not scroll itself; the long lists inside scroll (virtualization).
export function Tabs({
label,
items,
selectedId,
onSelect,
look = 'tool',
onPin,
add,
empty,
keepAlive = false,
}: TabsProps) {
return (
<AriaTabs
className={styles.tabs}
selectedKey={selectedId}
onSelectionChange={(key) => {
onSelect(String(key));
}}
>
{/* Delete closes the selected tab. The handler stands on the row and not on the tab itself:
the tab primitive does not take keyboard events, and activation on the row is automatic —
focus and selection are always on the same tab. */}
<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?.();
}
}}
>
{/* An empty row is not drawn at all: a tablist without a single tab is an accessibility
violation, and the axe gate in the screenshot cycle fails the build on it. */}
{items.length > 0 && (
<TabList className={styles.list} aria-label={label} items={items}>
{(item) => (
<Tab
className={styles.tab}
id={item.id}
data-preview={item.preview}
onDoubleClick={() => onPin?.(item.id)}
>
<span className={styles.title}>{item.label}</span>
{/* The cross is not a button, and that is not carelessness: children of the tab
role are presentational (ARIA), so a nested focusable element falls out of the
accessibility tree while staying in the tab order. The mouse closes with the
cross, the keyboard with Delete. */}
{item.onClose && (
<span
className={styles.close}
aria-hidden="true"
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={item.onClose}
>
<X {...icon} />
</span>
)}
</Tab>
)}
</TabList>
)}
{add && (
<span className={styles.add}>
<Button look="icon" aria-label={add.label} onPress={add.onPress}>
<Plus {...icon} />
</Button>
</span>
)}
</div>
{items.length === 0 && <div className={styles.panel}>{empty}</div>}
{items.map((item) => (
<TabPanel className={styles.panel} key={item.id} id={item.id} shouldForceMount={keepAlive}>
{item.content}
</TabPanel>
))}
</AriaTabs>
);
}