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 ( { 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. */}
{ 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 && ( {(item) => ( onPin?.(item.id)} > {item.label} {/* 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 && ( )} )} )} {add && ( )}
{items.length === 0 &&
{empty}
} {items.map((item) => ( {item.content} ))}
); }