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

507 lines
17 KiB
TypeScript

import React, {
ComponentProps,
MutableRefObject,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { Box, Header, Line, Scroll, Text, as, color, 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;
// Round the inner column's right-side corners (TR + BR). Off by
// default — the page-nav right rounding was intentionally dropped
// from the standard pattern in commit 74d32eb. Re-enabled only at
// callsites that explicitly opt in. Currently unused in the tree
// (the Settings nav tried it and was reverted on product feedback),
// kept as a primitive for future nested-horseshoe surfaces that
// want a fully-rounded "island" nav between two voids.
roundedRight?: boolean;
// Background surface tone for the inner column. Default
// `'background'` reads the standard `Background.Container` (Dawn
// bg2 = #0d0e11) — the deepest surface, what every tab's nav uses.
// `'surfaceVariant'` swaps to `color.SurfaceVariant.Container`
// (Dawn bg = #181a20) — the «raised» chat-pane tone used by 1-1
// chats and the composer; visually a step lighter than the DM
// list, marking the Settings nav as a distinct surface without
// jumping outside the Dawn palette.
surface?: 'background' | 'surfaceVariant';
};
export function PageNav({
size,
resizable,
roundedRight,
surface,
children,
}: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const horseshoe = useHorseshoeEnabled();
if (resizable && !isMobile) {
return <ResizablePageNav>{children}</ResizablePageNav>;
}
const radii = toRem(VOJO_HORSESHOE_RADIUS_PX);
const roundedRightStyle =
roundedRight && horseshoe
? { borderTopRightRadius: radii, borderBottomRightRadius: radii }
: undefined;
// Inline `backgroundColor` overrides whatever `PageNavInnerWebHorseshoe`
// sets via vanilla-extract — inline style wins on specificity, so
// we can override the default `Background.Container` without
// touching the recipe.
const surfaceStyle =
surface === 'surfaceVariant'
? { backgroundColor: color.SurfaceVariant.Container }
: undefined;
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)',
...roundedRightStyle,
...surfaceStyle,
}}
>
{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 (
// `minHeight: 0` is the canonical flexbox fix for a scroll child inside
// a flex column: without it the Scroll's intrinsic content height pushes
// the wrapper to that height, the column overflows the viewport, and
// every sibling with default `flex-shrink: 1` (header / footer rows)
// gets squashed below its natural height.
<Box grow="Yes" direction="Column" style={{ minHeight: 0 }}>
<Scroll
ref={scrollRef}
variant="Background"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={css.PageNavContent}>{children}</div>
</Scroll>
</Box>
);
}
type PageVariantProps = {
// Background surface tone. Default `'Surface'` (Dawn bg2, #0d0e11)
// — the deepest tone used by every sub-page elsewhere in the app.
// `'SurfaceVariant'` (Dawn bg, #181a20) is one notch lighter and
// used by the Settings sub-pages so they read on the same surface
// tone as the Settings menu (which itself uses
// `surface="surfaceVariant"` on its PageNav). Other variants
// (`'Background'`, `'Primary'`, etc.) pass through unchanged in
// case a future surface needs a different tone — see folds tokens
// for the full set.
variant?: 'Background' | 'Surface' | 'SurfaceVariant' | 'Primary' | 'Secondary';
};
export const Page = as<'div', PageVariantProps>(
({ className, variant = 'Surface', ...props }, ref) => (
<Box
grow="Yes"
direction="Column"
className={classNames(ContainerColor({ variant }), 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} />
));