textmachine/frontend/src/shell/Shell.tsx

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;