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 { Box, Header, Line, Scroll, Text, as } from 'folds';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
import {
|
||||||
|
SIDEBAR_WIDTH_MIN,
|
||||||
|
clampSidebarWidth,
|
||||||
|
sidebarWidthAtom,
|
||||||
|
} from '../../state/sidebarWidth';
|
||||||
|
|
||||||
type PageRootProps = {
|
type PageRootProps = {
|
||||||
nav: ReactNode;
|
nav: ReactNode;
|
||||||
|
|
@ -26,11 +40,20 @@ export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
|
|
||||||
type ClientDrawerLayoutProps = {
|
type ClientDrawerLayoutProps = {
|
||||||
children: ReactNode;
|
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 screenSize = useScreenSizeContext();
|
||||||
const isMobile = screenSize === ScreenSize.Mobile;
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
|
||||||
|
if (resizable && !isMobile) {
|
||||||
|
return <ResizablePageNav>{children}</ResizablePageNav>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
grow={isMobile ? 'Yes' : undefined}
|
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>(
|
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
|
||||||
({ className, outlined, ...props }, ref) => (
|
({ className, outlined, ...props }, ref) => (
|
||||||
<Header
|
<Header
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,68 @@ import { style } from '@vanilla-extract/css';
|
||||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
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({
|
export const PageNav = recipe({
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export function Bots() {
|
||||||
const bots = useBotPresets();
|
const bots = useBotPresets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav size="500">
|
<PageNav resizable>
|
||||||
<DirectStreamHeader />
|
<DirectStreamHeader />
|
||||||
<PageNavContent>
|
<PageNavContent>
|
||||||
<Box
|
<Box
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
||||||
// footer panes only make sense once a Space is in context.
|
// footer panes only make sense once a Space is in context.
|
||||||
export function ChannelsRootNav() {
|
export function ChannelsRootNav() {
|
||||||
return (
|
return (
|
||||||
<PageNav size="500">
|
<PageNav resizable>
|
||||||
<DirectStreamHeader />
|
<DirectStreamHeader />
|
||||||
<PageNavContent>
|
<PageNavContent>
|
||||||
<div />
|
<div />
|
||||||
|
|
@ -50,7 +50,7 @@ export function Channels() {
|
||||||
}, [space.roomId]);
|
}, [space.roomId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav size="500">
|
<PageNav resizable>
|
||||||
<DirectStreamHeader />
|
<DirectStreamHeader />
|
||||||
<PageNavContent scrollRef={scrollRef}>
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
<ChannelsList scrollRef={scrollRef} />
|
<ChannelsList scrollRef={scrollRef} />
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ export function Direct() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav size="500">
|
<PageNav resizable>
|
||||||
<DirectStreamHeader />
|
<DirectStreamHeader />
|
||||||
{noRoomToDisplay ? (
|
{noRoomToDisplay ? (
|
||||||
<DirectEmpty />
|
<DirectEmpty />
|
||||||
|
|
|
||||||
|
|
@ -440,7 +440,7 @@ export function Space() {
|
||||||
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
|
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav>
|
<PageNav resizable>
|
||||||
<SpaceHeader />
|
<SpaceHeader />
|
||||||
<PageNavContent scrollRef={scrollRef}>
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
<Box direction="Column" gap="300">
|
<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