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 { Footer } from './Footer';
|
||||||
import { SidebarChromeContext } from './sidebar-chrome';
|
import { SidebarChromeContext } from './sidebar-chrome';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './SidebarResizer';
|
||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
|
|
||||||
export function AppLayout(): React.JSX.Element {
|
export function AppLayout(): React.JSX.Element {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
|
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 [forcedHidden, setForcedHidden] = useState(false);
|
||||||
const collapsed = sidebarCollapsed || forcedHidden;
|
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
|
// Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of
|
||||||
// which element has focus.
|
// which element has focus.
|
||||||
@ -32,8 +39,12 @@ export function AppLayout(): React.JSX.Element {
|
|||||||
sidebarCollapsed={collapsed}
|
sidebarCollapsed={collapsed}
|
||||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
/>
|
/>
|
||||||
<div className="app-body">
|
<div
|
||||||
|
className="app-body"
|
||||||
|
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
|
||||||
|
>
|
||||||
<Sidebar collapsed={collapsed} />
|
<Sidebar collapsed={collapsed} />
|
||||||
|
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
|
||||||
<main className="main">
|
<main className="main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Footer />
|
<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;
|
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 {
|
.sidebar__hint {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
"sidebar": {
|
"sidebar": {
|
||||||
"expand": "Seitenleiste einblenden",
|
"expand": "Seitenleiste einblenden",
|
||||||
"collapse": "Seitenleiste ausblenden",
|
"collapse": "Seitenleiste ausblenden",
|
||||||
|
"resize": "Breite der Seitenleiste anpassen",
|
||||||
"label": "Seiten",
|
"label": "Seiten",
|
||||||
"placeholder": "Deine Teiche und Seiten erscheinen hier.",
|
"placeholder": "Deine Teiche und Seiten erscheinen hier.",
|
||||||
"empty": "Noch keine Seiten.",
|
"empty": "Noch keine Seiten.",
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
"sidebar": {
|
"sidebar": {
|
||||||
"expand": "Show sidebar",
|
"expand": "Show sidebar",
|
||||||
"collapse": "Hide sidebar",
|
"collapse": "Hide sidebar",
|
||||||
|
"resize": "Resize sidebar",
|
||||||
"label": "Pages",
|
"label": "Pages",
|
||||||
"placeholder": "Your ponds and pages will appear here.",
|
"placeholder": "Your ponds and pages will appear here.",
|
||||||
"empty": "No pages yet.",
|
"empty": "No pages yet.",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user