vojo/src/app/components/page/Page.tsx

451 lines
14 KiB
TypeScript

import React, {
ComponentProps,
MutableRefObject,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { Box, Header, Line, Scroll, Text, as, toRem } 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';
import {
VOJO_HORSESHOE_VOID_COLOR,
VOJO_HORSESHOE_GAP_PX,
VOJO_HORSESHOE_RADIUS_PX,
} from '../../styles/horseshoe';
import { isNativePlatform } from '../../utils/capacitor';
// Off-switch for the web horseshoe layout. Default `true` (horseshoe
// enabled on web non-mobile). Set to `false` by any container that
// embeds `PageRoot` outside the page level — currently `Modal500` for
// the settings dialogs (Settings / SpaceSettings / RoomSettings), so
// the modal's nav rail doesn't end up with a 12px void column and
// rounded corners inside a fixed 500px shell.
export const HorseshoeEnabledContext = createContext<boolean>(true);
const useHorseshoeEnabled = (): boolean => {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const contextEnabled = useContext(HorseshoeEnabledContext);
return contextEnabled && !isMobile && !isNativePlatform();
};
type PageRootProps = {
nav: ReactNode;
children: ReactNode;
};
export function PageRoot({ nav, children }: PageRootProps) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
// Horseshoe (gap + rounded chat wrapper) is web-only AND non-mobile-
// only AND not embedded (the `HorseshoeEnabledContext` lets parents
// like `Modal500` switch it off — settings dialogs render `PageRoot`
// inside a fixed-width modal where the void column doesn't belong).
// Native keeps the original single `<Line>` separator; mobile shows
// one panel at a time so no separator is needed at all.
const horseshoe = useHorseshoeEnabled();
const horseshoeRadius = toRem(VOJO_HORSESHOE_RADIUS_PX);
if (horseshoe) {
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{/* Page-nav slot — its right edge stays square (the original
TR / BR rounding was reverted). The 12px void gap below
still creates a visible seam between page-nav and the chat
panel, and the resize handle inside `ResizablePageNav` is
shifted into that void via the inline style below. */}
{nav}
<Box
shrink="No"
style={{
width: toRem(VOJO_HORSESHOE_GAP_PX),
backgroundColor: VOJO_HORSESHOE_VOID_COLOR,
}}
/>
{/* Right-side panel: two layers, both filling the same flex
slot. Outer paints the void colour; inner re-paints the
standard Background and clips its content with
`overflow:hidden + border-radius`, so the inner's rounded
TL/BL carves expose the outer's void. The explicit
Background bg on the inner is what keeps the panel's
apparent colour unchanged for routes whose content has no
opaque bg of its own (e.g. ChannelsLanding) — without it
the outer void would bleed through. */}
<Box
grow="Yes"
style={{ minWidth: 0, backgroundColor: VOJO_HORSESHOE_VOID_COLOR }}
>
<Box
grow="Yes"
className={ContainerColor({ variant: 'Background' })}
style={{
minWidth: 0,
overflow: 'hidden',
borderTopLeftRadius: horseshoeRadius,
borderBottomLeftRadius: horseshoeRadius,
}}
>
{children}
</Box>
</Box>
</Box>
);
}
return (
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
{nav}
{!isMobile && <Line variant="Background" size="300" direction="Vertical" />}
{children}
</Box>
);
}
type ClientDrawerLayoutProps = {
children: ReactNode;
resizable?: boolean;
};
export function PageNav({
size,
resizable,
children,
}: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const horseshoe = useHorseshoeEnabled();
if (resizable && !isMobile) {
return <ResizablePageNav>{children}</ResizablePageNav>;
}
return (
<Box
grow={isMobile ? 'Yes' : undefined}
className={css.PageNav({ size })}
shrink={isMobile ? 'Yes' : 'No'}
>
<Box
grow="Yes"
direction="Column"
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
// Top inset for native: `#root` no longer reserves the status-bar
// height (src/index.css), so the page-nav extends to the screen
// top. The padding here pushes the page-nav header (workspace
// tabs, etc.) below the status-bar icons. Applied at the inner
// column rather than at the `PageNavHeader` recipe because the
// recipe uses a Folds `<Header size="...">` with a fixed height —
// padding there would clip the header content. `--vojo-safe-top`
// is 0 on web and inside Modal500-hosted dialogs.
style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }}
>
{children}
</Box>
</Box>
);
}
function ResizablePageNav({ children }: { children: ReactNode }) {
const navRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const horseshoe = useHorseshoeEnabled();
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"
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
// Same native safe-top inset as the regular PageNav above —
// `var(--vojo-safe-top)` is 0 on web (where resizable is used)
// but kept here for symmetry / future-proofing.
style={{ paddingTop: 'var(--vojo-safe-top, 0px)' }}
>
{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}
// On web the page-nav is followed by the horseshoe void gap
// instead of the original `<Line>` separator — shift the
// handle so its hit-area fills exactly that void instead of
// bleeding onto the chat surface. Both dimensions are in
// `rem` to match the gap Box width (also `toRem(...)`), so
// they stay aligned across non-default root font-sizes.
// Native (or any context that disables the horseshoe, e.g.
// inside `Modal500`) keeps the CSS default which lines up
// with the Line.
style={
horseshoe
? {
right: `-${toRem(VOJO_HORSESHOE_GAP_PX)}`,
width: toRem(VOJO_HORSESHOE_GAP_PX),
}
: undefined
}
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
className={classNames(css.PageNavHeader({ outlined }), className)}
variant="Background"
size="600"
{...props}
ref={ref}
/>
)
);
export function PageNavContent({
scrollRef,
children,
}: {
children: ReactNode;
scrollRef?: MutableRefObject<HTMLDivElement | null>;
}) {
return (
<Box grow="Yes" direction="Column">
<Scroll
ref={scrollRef}
variant="Background"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={css.PageNavContent}>{children}</div>
</Scroll>
</Box>
);
}
export const Page = as<'div'>(({ className, ...props }, ref) => (
<Box
grow="Yes"
direction="Column"
className={classNames(ContainerColor({ variant: 'Surface' }), className)}
{...props}
ref={ref}
/>
));
export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, outlined, balance, ...props }, ref) => (
<Header
as="header"
size="600"
className={classNames(css.PageHeader({ balance, outlined }), className)}
{...props}
ref={ref}
/>
)
);
export const PageContent = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
));
export function PageHeroEmpty({ children }: { children: ReactNode }) {
return (
<Box
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
{children}
</Box>
);
}
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box
direction="Column"
className={classNames(css.PageHeroSection, className)}
{...props}
ref={ref}
/>
)
);
export function PageHero({
icon,
title,
subTitle,
children,
}: {
icon?: ReactNode;
title: ReactNode;
subTitle?: ReactNode;
children?: ReactNode;
}) {
return (
<Box direction="Column" gap="400">
{icon && (
<Box direction="Column" alignItems="Center" gap="200">
{icon}
</Box>
)}
<Box as="h2" direction="Column" gap="200" alignItems="Center">
<Text align="Center" size="H2">
{title}
</Text>
{subTitle && (
<Text align="Center" priority="400">
{subTitle}
</Text>
)}
</Box>
{children}
</Box>
);
}
export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
<div className={classNames(css.PageContentCenter, className)} {...props} ref={ref} />
));