dorfteich/apps/web/src/layout/SidebarResizer.tsx
Claude Fable 5 6c4f37ef91
Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Successful in 3m35s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m45s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Has been cancelled
Make the sidebar width drag-resizable and persistent (#99)
- SidebarResizer: pointer-drag handle on the sidebar's right edge,
  keyboard-adjustable (arrows, Home/End), double-click resets to the
  16rem default; width clamped to 12-32rem
- AppLayout persists the width via usePersistentState (ui.sidebar.width)
  and sets --sidebar-width inline on .app-body, so collapse/force-hide
  keep animating from/to the chosen width
- localized aria-label (de+en), handle hidden while collapsed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 04:16:56 +02:00

87 lines
3.0 KiB
TypeScript

import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
/** Sidebar width bounds and default, in rem (issue #99). */
export const SIDEBAR_MIN_WIDTH_REM = 12;
export const SIDEBAR_MAX_WIDTH_REM = 32;
export const SIDEBAR_DEFAULT_WIDTH_REM = 16;
/** Step for keyboard adjustment, in rem. */
const KEYBOARD_STEP_REM = 1;
export function clampSidebarWidth(rem: number): number {
if (!Number.isFinite(rem)) return SIDEBAR_DEFAULT_WIDTH_REM;
return Math.min(SIDEBAR_MAX_WIDTH_REM, Math.max(SIDEBAR_MIN_WIDTH_REM, rem));
}
interface SidebarResizerProps {
widthRem: number;
onResize: (widthRem: number) => void;
}
/**
* Drag handle on the sidebar's right edge. Pointer-draggable, keyboard-
* adjustable (arrow keys, Home/End), double-click resets to the default.
*/
export function SidebarResizer({ widthRem, onResize }: SidebarResizerProps): React.JSX.Element {
const { t } = useTranslation();
const dragStart = useRef<{ pointerX: number; widthRem: number } | null>(null);
function remToPx(): number {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
}
function handlePointerDown(event: React.PointerEvent<HTMLDivElement>): void {
// preventDefault (drag/selection guard) also swallows dblclick, so the
// double-click reset is detected via the click count here.
event.preventDefault();
if (event.detail >= 2) {
dragStart.current = null;
onResize(SIDEBAR_DEFAULT_WIDTH_REM);
return;
}
dragStart.current = { pointerX: event.clientX, widthRem };
event.currentTarget.setPointerCapture(event.pointerId);
}
function handlePointerMove(event: React.PointerEvent<HTMLDivElement>): void {
if (!dragStart.current) return;
const deltaRem = (event.clientX - dragStart.current.pointerX) / remToPx();
onResize(clampSidebarWidth(dragStart.current.widthRem + deltaRem));
}
function handlePointerUp(event: React.PointerEvent<HTMLDivElement>): void {
dragStart.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
let next: number | null = null;
if (event.key === 'ArrowLeft') next = widthRem - KEYBOARD_STEP_REM;
else if (event.key === 'ArrowRight') next = widthRem + KEYBOARD_STEP_REM;
else if (event.key === 'Home') next = SIDEBAR_MIN_WIDTH_REM;
else if (event.key === 'End') next = SIDEBAR_MAX_WIDTH_REM;
if (next === null) return;
event.preventDefault();
onResize(clampSidebarWidth(next));
}
return (
<div
className="sidebar-resizer"
role="separator"
aria-orientation="vertical"
aria-label={t('layout.sidebar.resize')}
aria-valuenow={Math.round(widthRem)}
aria-valuemin={SIDEBAR_MIN_WIDTH_REM}
aria-valuemax={SIDEBAR_MAX_WIDTH_REM}
tabIndex={0}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onDoubleClick={() => onResize(SIDEBAR_DEFAULT_WIDTH_REM)}
onKeyDown={handleKeyDown}
/>
);
}