438 lines
14 KiB
TypeScript
438 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}
|
|
>
|
|
{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}
|
|
>
|
|
{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} />
|
|
));
|