feat(sidebar): add resizable left page-nav via pointer/keyboard with localStorage-persisted width, clamped [320, viewport/3] and tactile min/max indicator

This commit is contained in:
v.lagerev 2026-05-11 02:49:39 +03:00
parent 1e7043c2c0
commit 98f4d2a8f5
7 changed files with 262 additions and 7 deletions

View file

@ -1,9 +1,23 @@
import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
import React, {
ComponentProps,
MutableRefObject,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Box, Header, Line, Scroll, Text, as } from 'folds';
import { useAtom } from 'jotai';
import classNames from 'classnames';
import { ContainerColor } from '../../styles/ContainerColor.css';
import * as css from './style.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import {
SIDEBAR_WIDTH_MIN,
clampSidebarWidth,
sidebarWidthAtom,
} from '../../state/sidebarWidth';
type PageRootProps = {
nav: ReactNode;
@ -26,11 +40,20 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = {
children: ReactNode;
resizable?: boolean;
};
export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
export function PageNav({
size,
resizable,
children,
}: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
if (resizable && !isMobile) {
return <ResizablePageNav>{children}</ResizablePageNav>;
}
return (
<Box
grow={isMobile ? 'Yes' : undefined}
@ -44,6 +67,150 @@ export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNa
);
}
function ResizablePageNav({ children }: { children: ReactNode }) {
const navRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
const [vw, setVw] = useState<number>(
typeof window !== 'undefined' ? window.innerWidth : 1280
);
const [dragging, setDragging] = useState(false);
// Live width during a drag — kept in component state so we don't write to
// the localStorage-backed atom on every pointermove (hundreds of sync disk
// writes per drag). Atom is flushed once on pointerup.
const [liveWidth, setLiveWidth] = useState<number | null>(null);
useEffect(() => {
const onResize = () => setVw(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const maxW = Math.max(SIDEBAR_WIDTH_MIN, Math.floor(vw / 3));
const baseWidth = dragging && liveWidth !== null ? liveWidth : savedWidth;
const width = clampSidebarWidth(baseWidth, vw);
// On viewports too narrow for any meaningful range (max == min) we hide the
// handle entirely: leaving it pointer-active but un-draggable reads as a
// broken UI. Falls back to the static MIN width.
const canResize = maxW > SIDEBAR_WIDTH_MIN;
// During a drag, signal that the user has pushed past a clamp — min
// squishes the indicator, max stretches it, so the limit feels tactile.
const atMin = dragging && liveWidth !== null && liveWidth <= SIDEBAR_WIDTH_MIN;
const atMax = dragging && liveWidth !== null && liveWidth >= maxW;
// Cleanup body styles when the drag ends OR the component unmounts mid-drag
// (e.g. mobile breakpoint flip, route change, Alt-Tab without pointerup).
// Without this the page can get stuck with col-resize cursor and
// user-select: none on body until the next mutation.
useEffect(() => {
if (!dragging) return undefined;
return () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [dragging]);
const beginDrag = useCallback((pointerId: number) => {
setDragging(true);
setLiveWidth(null);
try {
handleRef.current?.setPointerCapture(pointerId);
} catch {
/* setPointerCapture is best-effort */
}
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, []);
const endDrag = useCallback(() => {
setDragging(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
setLiveWidth((current) => {
if (current !== null) {
setSavedWidth(clampSidebarWidth(current, window.innerWidth));
}
return null;
});
}, [setSavedWidth]);
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || !canResize) return;
e.preventDefault();
beginDrag(e.pointerId);
},
[beginDrag, canResize]
);
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging || !navRef.current) return;
const rect = navRef.current.getBoundingClientRect();
setLiveWidth(clampSidebarWidth(e.clientX - rect.left, window.innerWidth));
},
[dragging]
);
const onStopPointer = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
try {
handleRef.current?.releasePointerCapture(e.pointerId);
} catch {
/* releasePointerCapture is best-effort */
}
endDrag();
},
[endDrag]
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (!canResize) return;
const step = e.shiftKey ? 64 : 16;
let next: number | null = null;
if (e.key === 'ArrowLeft') next = savedWidth - step;
else if (e.key === 'ArrowRight') next = savedWidth + step;
else if (e.key === 'Home') next = SIDEBAR_WIDTH_MIN;
else if (e.key === 'End') next = maxW;
if (next === null) return;
e.preventDefault();
setSavedWidth(clampSidebarWidth(next, vw));
},
[savedWidth, vw, maxW, canResize, setSavedWidth]
);
return (
<Box ref={navRef} className={css.PageNavResizable} style={{ width }}>
<Box grow="Yes" direction="Column">
{children}
</Box>
{canResize && (
<div
ref={handleRef}
role="separator"
aria-orientation="vertical"
aria-valuenow={width}
aria-valuemin={SIDEBAR_WIDTH_MIN}
aria-valuemax={maxW}
aria-label="Resize sidebar"
tabIndex={0}
className={css.PageNavResizeHandle}
data-dragging={dragging || undefined}
data-at-min={atMin || undefined}
data-at-max={atMax || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onStopPointer}
onPointerCancel={onStopPointer}
onLostPointerCapture={onStopPointer}
onKeyDown={onKeyDown}
/>
)}
</Box>
);
}
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
({ className, outlined, ...props }, ref) => (
<Header

View file

@ -2,6 +2,68 @@ import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const PageNavResizable = style({
position: 'relative',
flexShrink: 0,
flexGrow: 0,
});
export const PageNavResizeHandle = style({
position: 'absolute',
top: 0,
bottom: 0,
right: -3,
width: 7,
cursor: 'col-resize',
zIndex: 1,
background: 'transparent',
touchAction: 'none',
outline: 'none',
selectors: {
'&::before': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: 2,
height: toRem(36),
transform: 'translate(-50%, -50%)',
borderRadius: 1,
backgroundColor: color.Surface.OnContainer,
opacity: 0,
transition:
'opacity 140ms ease, height 140ms ease, width 140ms ease, background-color 140ms ease',
},
'&:hover::before, &:focus-visible::before': {
opacity: 0.25,
},
'&:focus-visible::before': {
backgroundColor: color.Primary.Main,
opacity: 0.45,
},
'&[data-dragging="true"]::before': {
opacity: 0.55,
height: toRem(48),
backgroundColor: color.Primary.Main,
},
// Limit feedback: when the user drags past a clamp the width stops
// moving and the indicator deforms to signal it. Min = spring crushed
// against a wall (slight squish, thicker). Max = rubber band stretched
// (taller, full opacity). Activates only during drag so the resting
// state stays calm.
'&[data-dragging="true"][data-at-min="true"]::before': {
height: toRem(28),
width: 3,
opacity: 0.85,
},
'&[data-dragging="true"][data-at-max="true"]::before': {
height: toRem(76),
width: 2,
opacity: 0.9,
},
},
});
export const PageNav = recipe({
variants: {
size: {

View file

@ -22,7 +22,7 @@ export function Bots() {
const bots = useBotPresets();
return (
<PageNav size="500">
<PageNav resizable>
<DirectStreamHeader />
<PageNavContent>
<Box

View file

@ -12,7 +12,7 @@ import { ACTIVE_SPACE_KEY } from './useActiveSpace';
// footer panes only make sense once a Space is in context.
export function ChannelsRootNav() {
return (
<PageNav size="500">
<PageNav resizable>
<DirectStreamHeader />
<PageNavContent>
<div />
@ -50,7 +50,7 @@ export function Channels() {
}, [space.roomId]);
return (
<PageNav size="500">
<PageNav resizable>
<DirectStreamHeader />
<PageNavContent scrollRef={scrollRef}>
<ChannelsList scrollRef={scrollRef} />

View file

@ -197,7 +197,7 @@ export function Direct() {
});
return (
<PageNav size="500">
<PageNav resizable>
<DirectStreamHeader />
{noRoomToDisplay ? (
<DirectEmpty />

View file

@ -440,7 +440,7 @@ export function Space() {
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
return (
<PageNav>
<PageNav resizable>
<SpaceHeader />
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">

View file

@ -0,0 +1,26 @@
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
export const SIDEBAR_WIDTH_KEY = 'vojo_sidebar_width';
export const SIDEBAR_WIDTH_MIN = 320;
export const SIDEBAR_WIDTH_DEFAULT = 416;
const readSidebarWidth = (key: string): number => {
const raw = getLocalStorageItem<unknown>(key, SIDEBAR_WIDTH_DEFAULT);
const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : SIDEBAR_WIDTH_DEFAULT;
return Math.max(SIDEBAR_WIDTH_MIN, Math.round(value));
};
export const sidebarWidthAtom = atomWithLocalStorage<number>(
SIDEBAR_WIDTH_KEY,
readSidebarWidth,
setLocalStorageItem
);
export const clampSidebarWidth = (px: number, viewportWidth: number): number => {
const max = Math.max(SIDEBAR_WIDTH_MIN, Math.floor(viewportWidth / 3));
return Math.max(SIDEBAR_WIDTH_MIN, Math.min(max, Math.round(px)));
};