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:
parent
c992e910ee
commit
023a6a439c
7 changed files with 262 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function Bots() {
|
|||
const bots = useBotPresets();
|
||||
|
||||
return (
|
||||
<PageNav size="500">
|
||||
<PageNav resizable>
|
||||
<DirectStreamHeader />
|
||||
<PageNavContent>
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export function Direct() {
|
|||
});
|
||||
|
||||
return (
|
||||
<PageNav size="500">
|
||||
<PageNav resizable>
|
||||
<DirectStreamHeader />
|
||||
{noRoomToDisplay ? (
|
||||
<DirectEmpty />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
26
src/app/state/sidebarWidth.ts
Normal file
26
src/app/state/sidebarWidth.ts
Normal 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)));
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue