127 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|