import { PanelLeft, PanelRight } from 'lucide-react'; import { type ReactNode, useMemo, useState } from 'react'; import { Group, Panel, type PanelSize, Separator, useDefaultLayout } from 'react-resizable-panels'; import { useText } from '../i18n/text'; import { measures, px } from '../tokens/measures'; import { Button } from '../ui/Button'; import { icon } from '../ui/icon'; import { type Side, useLayout } from './layout'; import styles from './Shell.module.css'; interface Props { /** The name of the current context, in the centre of the top bar. */ title: string; /** The row of action icons at the right of the top bar. */ actions?: ReactNode; left: ReactNode; center: ReactNode; right: ReactNode; status: ReactNode; } type Limit = 'min' | 'max' | null; // A collapsed panel is not squeezed to zero, it leaves the layout together with its separator: // otherwise an extra gap is left in its place, and the measured inset of the shell is exactly 8px. // useDefaultLayout keeps the layout SEPARATELY for every set of visible panels, which is why the // widths come back on expanding — but the first entry into a new set starts from the defaults. export function Shell({ title, actions, left, center, right, status }: Props) { const text = useText(); const collapsed = useLayout((state) => state.collapsed); const toggle = useLayout((state) => state.toggle); const panelIds = useMemo( () => ['left', 'center', 'right'].filter((id) => collapsed[id as Side] !== true), [collapsed], ); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: 'shell', panelIds }); // The stop is the state of the SHELL, not of the library: the library does not tell about the // stop. Re-rendering here is cheap, because the content of the panels arrives as ready elements // in the props — React sees the same reference and does not go into the subtrees. const [limit, setLimit] = useState>({ left: null, right: null }); // The minimum of the panels is DIFFERENT: the bank table lives on the right, and narrower than // its own measure it does not squeeze. A shared comparison function would lie about the stop of // the left panel on every frame (caught by a scenario). const watch = (side: Side, min: number) => (size: PanelSize) => { setLimit((current) => { const next = limitOf(size, min); return current[side] === next ? current : { ...current, [side]: next }; }); }; return (
{/* The name of the button changes together with the state: the icon of a collapsed and of an expanded panel is one and the same, and without this the collapsedness is visible only to a sighted user — by the panel that has disappeared. */}

{title}

{actions}
{!collapsed.left && ( <> {left} )} {/* The centre stretches by a fraction, the side ones by pixels: at 1920 the extra width goes to the reader, and the reference columns stay of the width they are drawn for (measured 320px). A reader narrower than a side panel is pointless, so its minimum is that same width. */} {center} {!collapsed.right && ( <> {right} )}
); } // Half a pixel of tolerance: the library does not always bring the size to a whole number, and a // head-on equality would let the stop through every other time. function limitOf({ inPixels }: PanelSize, min: number): Limit { if (inPixels <= min + 0.5) return 'min'; if (inPixels >= measures['--panel-side-max'] - 0.5) return 'max'; return null; } const side = { defaultSize: px('--panel-side-width'), minSize: px('--panel-side-min'), maxSize: px('--panel-side-max'), groupResizeBehavior: 'preserve-pixel-size', } as const;