145 lines
5.6 KiB
TypeScript
145 lines
5.6 KiB
TypeScript
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<Record<Side, Limit>>({ 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 (
|
|
<div className={styles.shell}>
|
|
<header className={styles.topbar}>
|
|
{/* 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. */}
|
|
<div className={styles.side}>
|
|
<Button
|
|
look="icon"
|
|
aria-label={text(collapsed.left ? 'shell.expandLeft' : 'shell.collapseLeft')}
|
|
onPress={() => toggle('left')}
|
|
>
|
|
<PanelLeft {...icon} />
|
|
</Button>
|
|
<Button
|
|
look="icon"
|
|
aria-label={text(collapsed.right ? 'shell.expandRight' : 'shell.collapseRight')}
|
|
onPress={() => toggle('right')}
|
|
>
|
|
<PanelRight {...icon} />
|
|
</Button>
|
|
</div>
|
|
<h1 className={styles.title}>{title}</h1>
|
|
<div className={styles.side}>{actions}</div>
|
|
</header>
|
|
|
|
<Group
|
|
className={styles.body}
|
|
id="shell"
|
|
defaultLayout={defaultLayout}
|
|
onLayoutChanged={onLayoutChanged}
|
|
>
|
|
{!collapsed.left && (
|
|
<>
|
|
<Panel
|
|
{...side}
|
|
className={styles.slot}
|
|
id="left"
|
|
onResize={watch('left', measures['--panel-side-min'])}
|
|
>
|
|
{left}
|
|
</Panel>
|
|
<Separator className={styles.separator}>
|
|
<span className={styles.handle} data-limit={limit.left ?? undefined} />
|
|
</Separator>
|
|
</>
|
|
)}
|
|
{/* 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. */}
|
|
<Panel className={styles.slot} id="center" minSize={px('--panel-side-width')}>
|
|
{center}
|
|
</Panel>
|
|
{!collapsed.right && (
|
|
<>
|
|
<Separator className={styles.separator}>
|
|
<span className={styles.handle} data-limit={limit.right ?? undefined} />
|
|
</Separator>
|
|
<Panel
|
|
{...side}
|
|
defaultSize={px('--panel-context-width')}
|
|
minSize={px('--panel-context-min')}
|
|
className={styles.slot}
|
|
id="right"
|
|
onResize={watch('right', measures['--panel-context-min'])}
|
|
>
|
|
{right}
|
|
</Panel>
|
|
</>
|
|
)}
|
|
</Group>
|
|
|
|
<footer className={styles.statusbar}>{status}</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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;
|