Files
openhuman/app/src/components/layout/TwoPanelLayout.tsx
T

341 lines
12 KiB
TypeScript

import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
ensurePanelLayout,
type PanelLayout,
selectPanelLayout,
setSidebarVisible,
setSidebarWidth,
toggleSidebar,
} from '../../store/layoutSlice';
const namespace = 'two-panel-layout';
function debug(message: string, payload?: Record<string, unknown>) {
if (import.meta.env.DEV) {
console.debug(`[${namespace}] ${message}`, payload ?? {});
}
}
function clampWidth(width: number, min: number, max: number): number {
return Math.min(Math.max(width, min), max);
}
/**
* Subscribe to a two-pane layout's persisted geometry and get back the
* helpers external chrome needs to drive it (e.g. a hamburger button living
* in some other header). Reads the SAME slice state `TwoPanelLayout` renders
* from, so toggles stay in sync.
*/
export function useTwoPanelLayout(id: string, defaults?: Partial<PanelLayout>) {
const dispatch = useAppDispatch();
const layout = useAppSelector(selectPanelLayout(id, defaults));
const show = useCallback(
(visible: boolean) => dispatch(setSidebarVisible({ id, visible })),
[dispatch, id]
);
const toggle = useCallback(() => dispatch(toggleSidebar({ id })), [dispatch, id]);
return {
sidebarVisible: layout.sidebarVisible,
sidebarWidth: layout.sidebarWidth,
showSidebar: show,
toggleSidebar: toggle,
};
}
interface TwoPanelLayoutProps {
/** Stable id used as the persistence key for this layout's geometry. */
id: string;
/** Content of the mini sidebar (left pane). */
sidebar: ReactNode;
/** Main content (right pane). */
children: ReactNode;
/** Sidebar visibility on first ever mount (before any persisted state). */
defaultSidebarVisible?: boolean;
/** Sidebar width in px on first ever mount. */
defaultSidebarWidth?: number;
/** Minimum sidebar width while dragging. */
minSidebarWidth?: number;
/** Maximum sidebar width while dragging. */
maxSidebarWidth?: number;
/**
* Force the sidebar open regardless of persisted state (e.g. an onboarding
* lockdown where the sidebar must always show). The persisted preference is
* untouched, so it restores once the force is lifted.
*/
forceSidebarVisible?: boolean;
/** Step (px) the keyboard divider moves per arrow press. */
keyboardStep?: number;
className?: string;
sidebarClassName?: string;
contentClassName?: string;
/**
* Shared appearance applied to BOTH panes — the card background, rounded
* corners, border and shadow live here (not in the panes' own content) so
* every two-pane screen gets a consistent look for free. Pass `''` to opt
* out (e.g. a flush, borderless layout).
*/
paneClassName?: string;
/**
* Show a thin rail with a reopen button when the sidebar is hidden. Defaults
* to false because chat surfaces its own toggle in the header; standalone
* uses can opt in.
*/
showCollapsedRail?: boolean;
/**
* Show the visible grab handle on the resize divider. When false the divider
* is still draggable (and shows a faint line on hover/focus) but renders no
* resting holder — a cleaner look for screens that don't want the affordance
* front-and-center. Defaults to true.
*/
showDividerHandle?: boolean;
/**
* Join the two panes into a single bordered card with no gap between them: the
* shared edge becomes a flush, hairline drag divider. This is the default for
* every two-pane surface; pass `false` for the legacy split-card look with a
* gutter divider (no current callers).
*/
seamless?: boolean;
}
/** Default card look shared by both panes. */
const DEFAULT_PANE_CLASS = 'bg-surface rounded-2xl shadow-soft border border-line';
const DEFAULT_MIN_WIDTH = 180;
const DEFAULT_MAX_WIDTH = 480;
const DEFAULT_KEYBOARD_STEP = 16;
/**
* A reusable two-pane shell: a resizable mini sidebar on the left and main
* content on the right. Visibility and the dragged width persist per `id` via
* the Redux `layout` slice, so the layout is remembered across reloads.
*
* Resize: drag the divider between the panes (pointer) or focus it and use the
* arrow keys. Width is clamped to [minSidebarWidth, maxSidebarWidth] and only
* committed to the store on drag end to avoid thrashing redux-persist.
*/
export default function TwoPanelLayout({
id,
sidebar,
children,
defaultSidebarVisible = false,
defaultSidebarWidth,
minSidebarWidth = DEFAULT_MIN_WIDTH,
maxSidebarWidth = DEFAULT_MAX_WIDTH,
forceSidebarVisible = false,
keyboardStep = DEFAULT_KEYBOARD_STEP,
className = '',
sidebarClassName = '',
contentClassName = '',
paneClassName = DEFAULT_PANE_CLASS,
showCollapsedRail = false,
showDividerHandle = true,
seamless = true,
}: TwoPanelLayoutProps) {
const { t } = useT();
const dispatch = useAppDispatch();
const layout = useAppSelector(
selectPanelLayout(id, {
sidebarVisible: defaultSidebarVisible,
...(defaultSidebarWidth != null ? { sidebarWidth: defaultSidebarWidth } : {}),
})
);
// Seed persisted geometry from this component's defaults exactly once per id.
useEffect(() => {
dispatch(
ensurePanelLayout({
id,
defaults: {
sidebarVisible: defaultSidebarVisible,
...(defaultSidebarWidth != null ? { sidebarWidth: defaultSidebarWidth } : {}),
},
})
);
// Intentionally only on id change — defaults are a first-mount seed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const isOpen = forceSidebarVisible || layout.sidebarVisible;
// Live width while dragging is kept local (and applied via inline style) so
// we don't dispatch — and re-persist — on every pointer move.
const [dragWidth, setDragWidth] = useState<number | null>(null);
const dragWidthRef = useRef<number | null>(null);
const persistedWidth = clampWidth(layout.sidebarWidth, minSidebarWidth, maxSidebarWidth);
const width = dragWidth ?? persistedWidth;
const commitWidth = useCallback(
(next: number) => {
const clamped = clampWidth(Math.round(next), minSidebarWidth, maxSidebarWidth);
dispatch(setSidebarWidth({ id, width: clamped }));
debug('commit width', { id, width: clamped });
},
[dispatch, id, minSidebarWidth, maxSidebarWidth]
);
// Active-drag teardown, stashed so an unmount mid-drag can detach the global
// listeners. Each drag installs locally-scoped `pointermove`/`pointerup`
// handlers (hoisted function declarations so they can reference each other),
// keeping the resize self-contained without inter-callback dependencies.
const dragCleanupRef = useRef<(() => void) | null>(null);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = width;
dragWidthRef.current = startWidth;
setDragWidth(startWidth);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
function handleMove(ev: PointerEvent) {
const next = clampWidth(
startWidth + (ev.clientX - startX),
minSidebarWidth,
maxSidebarWidth
);
dragWidthRef.current = next;
setDragWidth(next);
}
function detach() {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
document.body.style.removeProperty('cursor');
document.body.style.removeProperty('user-select');
dragCleanupRef.current = null;
}
function stop() {
detach();
const finalWidth = dragWidthRef.current;
dragWidthRef.current = null;
setDragWidth(null);
if (finalWidth != null) commitWidth(finalWidth);
}
dragCleanupRef.current = detach;
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', stop);
debug('drag start', { id, startWidth });
},
[width, minSidebarWidth, maxSidebarWidth, commitWidth, id]
);
// Detach global listeners if we unmount mid-drag.
useLayoutEffect(() => {
return () => {
dragCleanupRef.current?.();
};
}, []);
const onDividerKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
commitWidth(persistedWidth - keyboardStep);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
commitWidth(persistedWidth + keyboardStep);
}
},
[commitWidth, persistedWidth, keyboardStep]
);
// In seamless mode the card lives on the wrapper that holds both panes, so the
// panes themselves carry no border/rounding and sit flush against the divider.
const paneCard = seamless ? '' : paneClassName;
const panes = (
<>
{isOpen && (
<>
<div
className={`flex-shrink-0 min-w-0 overflow-hidden ${paneCard} ${sidebarClassName}`}
style={{ width }}
data-testid={`two-panel-sidebar-${id}`}>
{sidebar}
</div>
{/* Drag handle / divider */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t('layout.resizeSidebar')}
aria-valuenow={Math.round(width)}
aria-valuemin={minSidebarWidth}
aria-valuemax={maxSidebarWidth}
tabIndex={0}
data-testid={`two-panel-divider-${id}`}
data-analytics-id="two-panel-resize-divider"
onPointerDown={onPointerDown}
onKeyDown={onDividerKeyDown}
className={
seamless
? // Flush hairline seam: 1px visible line, wider invisible hit
// area, highlights on hover/focus.
'group relative w-px flex-shrink-0 cursor-col-resize select-none self-stretch bg-surface-strong focus:outline-none'
: `group relative flex flex-shrink-0 cursor-col-resize select-none items-center justify-center self-stretch focus:outline-none ${
// Tighter gutter between panes when there's no visible handle.
showDividerHandle ? 'mx-1 w-3' : 'mx-0 w-1.5'
}`
}
title={t('layout.resizeSidebar')}>
{seamless ? (
<>
{/* Wider transparent grab strip straddling the 1px seam; z-10
keeps it above the adjacent panes so it stays grabbable. */}
<span className="absolute inset-y-0 -left-1 -right-1 z-10" />
{/* The seam line itself, brightened on hover/focus. */}
<span className="absolute inset-0 transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500" />
</>
) : (
/* Transparent hit area (full height) with a short grab handle
centered vertically. When the handle is hidden it stays
transparent at rest and only surfaces on hover/focus. */
<span
className={`h-10 w-1 rounded-full transition-colors group-hover:bg-primary-400 group-focus:bg-primary-500 ${
showDividerHandle ? 'bg-stone-400 dark:bg-neutral-500' : 'bg-transparent'
}`}
/>
)}
</div>
</>
)}
{!isOpen && showCollapsedRail && (
<button
type="button"
data-testid={`two-panel-reopen-${id}`}
data-analytics-id="two-panel-reopen-sidebar"
onClick={() => dispatch(setSidebarVisible({ id, visible: true }))}
title={t('layout.showSidebar')}
aria-label={t('layout.showSidebar')}
className="flex-shrink-0 w-6 self-stretch flex items-center justify-center text-content-faint hover:text-primary-500 hover:bg-surface-hover transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
<div className={`flex-1 min-w-0 overflow-hidden ${paneCard} ${contentClassName}`}>
{children}
</div>
</>
);
return (
<div className={`flex min-h-0 ${className}`}>
{seamless ? (
<div className={`flex min-h-0 flex-1 overflow-hidden ${DEFAULT_PANE_CLASS}`}>{panes}</div>
) : (
panes
)}
</div>
);
}