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
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:
parent
a9e901c449
commit
6c4f37ef91
@ -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 />
|
||||
|
||||
86
apps/web/src/layout/SidebarResizer.tsx
Normal file
86
apps/web/src/layout/SidebarResizer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user