From 023a6a439c8e0edc21aa3e5ba7692163d9190b86 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 11 May 2026 02:49:39 +0300 Subject: [PATCH] feat(sidebar): add resizable left page-nav via pointer/keyboard with localStorage-persisted width, clamped [320, viewport/3] and tactile min/max indicator --- src/app/components/page/Page.tsx | 171 ++++++++++++++++++++- src/app/components/page/style.css.ts | 62 ++++++++ src/app/pages/client/bots/Bots.tsx | 2 +- src/app/pages/client/channels/Channels.tsx | 4 +- src/app/pages/client/direct/Direct.tsx | 2 +- src/app/pages/client/space/Space.tsx | 2 +- src/app/state/sidebarWidth.ts | 26 ++++ 7 files changed, 262 insertions(+), 7 deletions(-) create mode 100644 src/app/state/sidebarWidth.ts diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 2afba267..1a4dafe8 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -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 {children}; + } + return ( (null); + const handleRef = useRef(null); + const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom); + const [vw, setVw] = useState( + 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(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) => { + if (e.button !== 0 || !canResize) return; + e.preventDefault(); + beginDrag(e.pointerId); + }, + [beginDrag, canResize] + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + 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) => { + try { + handleRef.current?.releasePointerCapture(e.pointerId); + } catch { + /* releasePointerCapture is best-effort */ + } + endDrag(); + }, + [endDrag] + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + 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 ( + + + {children} + + {canResize && ( +
+ )} + + ); +} + export const PageNavHeader = as<'header', css.PageNavHeaderVariants>( ({ className, outlined, ...props }, ref) => (
+ +
@@ -50,7 +50,7 @@ export function Channels() { }, [space.roomId]); return ( - + diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 3f2489bc..26e84f01 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -197,7 +197,7 @@ export function Direct() { }); return ( - + {noRoomToDisplay ? ( diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 2a7c6199..44b5a140 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -440,7 +440,7 @@ export function Space() { getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); return ( - + diff --git a/src/app/state/sidebarWidth.ts b/src/app/state/sidebarWidth.ts new file mode 100644 index 00000000..5635095c --- /dev/null +++ b/src/app/state/sidebarWidth.ts @@ -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(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( + 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))); +};