Make the sidebar width drag-resizable and persistent (#99)
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

- 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
This commit is contained in:
Claude Fable 5 2026-07-12 04:16:56 +02:00
parent a9e901c449
commit 6c4f37ef91
5 changed files with 116 additions and 1 deletions

View File

@ -5,12 +5,19 @@ import { usePersistentState } from '../lib/use-persistent-state';
import { Footer } from './Footer';
import { SidebarChromeContext } from './sidebar-chrome';
import { Sidebar } from './Sidebar';
import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './SidebarResizer';
import { TopBar } from './TopBar';
export function AppLayout(): React.JSX.Element {
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
const [sidebarWidth, setSidebarWidth] = usePersistentState(
'ui.sidebar.width',
SIDEBAR_DEFAULT_WIDTH_REM,
);
const [forcedHidden, setForcedHidden] = useState(false);
const collapsed = sidebarCollapsed || forcedHidden;
// Clamp on read too — the persisted value may predate a bounds change.
const widthRem = clampSidebarWidth(sidebarWidth);
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of
// which element has focus.
@ -32,8 +39,12 @@ export function AppLayout(): React.JSX.Element {
sidebarCollapsed={collapsed}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<div className="app-body">
<div
className="app-body"
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
>
<Sidebar collapsed={collapsed} />
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
<main className="main">
<Outlet />
<Footer />

View File

@ -0,0 +1,86 @@
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}
/>
);
}

View File

@ -99,6 +99,22 @@ button {
opacity: 0;
}
/* Drag handle on the sidebar's right edge (issue #99). */
.sidebar-resizer {
flex-shrink: 0;
width: 6px;
margin-left: -3px;
cursor: col-resize;
touch-action: none;
background: transparent;
}
.sidebar-resizer:hover,
.sidebar-resizer:focus-visible {
background: var(--color-accent);
outline: none;
}
.sidebar__hint {
color: var(--color-text-muted);
font-size: 0.9rem;

View File

@ -3,6 +3,7 @@
"sidebar": {
"expand": "Seitenleiste einblenden",
"collapse": "Seitenleiste ausblenden",
"resize": "Breite der Seitenleiste anpassen",
"label": "Seiten",
"placeholder": "Deine Teiche und Seiten erscheinen hier.",
"empty": "Noch keine Seiten.",

View File

@ -3,6 +3,7 @@
"sidebar": {
"expand": "Show sidebar",
"collapse": "Hide sidebar",
"resize": "Resize sidebar",
"label": "Pages",
"placeholder": "Your ponds and pages will appear here.",
"empty": "No pages yet.",