feat(horseshoe): split web page-nav and chat panel with 12px void gap and rounded inner corners on both sides
This commit is contained in:
parent
023a6a439c
commit
4d0b508ebb
5 changed files with 192 additions and 14 deletions
|
|
@ -2,6 +2,7 @@ import React, { ReactNode } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { HorseshoeEnabledContext } from './page';
|
||||||
|
|
||||||
type Modal500Props = {
|
type Modal500Props = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -20,7 +21,14 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="500" variant="Background">
|
<Modal size="500" variant="Background">
|
||||||
|
{/* PageRoot rendered inside the dialog (Settings,
|
||||||
|
SpaceSettings, RoomSettings) would otherwise pick up
|
||||||
|
the web horseshoe layout — void column + rounded
|
||||||
|
corners inside a fixed 500px shell. Disable horseshoe
|
||||||
|
for everything inside the modal. */}
|
||||||
|
<HorseshoeEnabledContext.Provider value={false}>
|
||||||
{children}
|
{children}
|
||||||
|
</HorseshoeEnabledContext.Provider>
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import React, {
|
||||||
ComponentProps,
|
ComponentProps,
|
||||||
MutableRefObject,
|
MutableRefObject,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, Header, Line, Scroll, Text, as } from 'folds';
|
import { Box, Header, Line, Scroll, Text, as, toRem } from 'folds';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
|
@ -18,6 +20,27 @@ import {
|
||||||
clampSidebarWidth,
|
clampSidebarWidth,
|
||||||
sidebarWidthAtom,
|
sidebarWidthAtom,
|
||||||
} from '../../state/sidebarWidth';
|
} 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 = {
|
type PageRootProps = {
|
||||||
nav: ReactNode;
|
nav: ReactNode;
|
||||||
|
|
@ -26,13 +49,82 @@ type PageRootProps = {
|
||||||
|
|
||||||
export function PageRoot({ nav, children }: PageRootProps) {
|
export function PageRoot({ nav, children }: PageRootProps) {
|
||||||
const screenSize = useScreenSizeContext();
|
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 — paints two void squares at its top-right and
|
||||||
|
bottom-right via `background-image` (the
|
||||||
|
`linear-gradient(SAME, SAME)` idiom = a solid-colour image).
|
||||||
|
The wrapper's bg sits naturally beneath its in-flow
|
||||||
|
children, so PageNavHeader and the bottom row paint their
|
||||||
|
normal Background colour over the void everywhere except in
|
||||||
|
their own rounded TR / BR carves — which expose the void.
|
||||||
|
No absolute positioning, no z-index gymnastics, and the
|
||||||
|
resize handle inside `ResizablePageNav` stays a sibling of
|
||||||
|
the clipped inner column so it isn't clipped. */}
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
backgroundImage: `linear-gradient(${VOJO_HORSESHOE_VOID_COLOR}, ${VOJO_HORSESHOE_VOID_COLOR}), linear-gradient(${VOJO_HORSESHOE_VOID_COLOR}, ${VOJO_HORSESHOE_VOID_COLOR})`,
|
||||||
|
backgroundSize: `${horseshoeRadius} ${horseshoeRadius}, ${horseshoeRadius} ${horseshoeRadius}`,
|
||||||
|
backgroundPosition: 'top right, bottom right',
|
||||||
|
backgroundRepeat: 'no-repeat, no-repeat',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nav}
|
||||||
|
</Box>
|
||||||
|
<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 (
|
return (
|
||||||
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||||
{nav}
|
{nav}
|
||||||
{screenSize !== ScreenSize.Mobile && (
|
{!isMobile && <Line variant="Background" size="300" direction="Vertical" />}
|
||||||
<Line variant="Background" size="300" direction="Vertical" />
|
|
||||||
)}
|
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -49,6 +141,7 @@ export function PageNav({
|
||||||
}: ClientDrawerLayoutProps & css.PageNavVariants) {
|
}: ClientDrawerLayoutProps & css.PageNavVariants) {
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const isMobile = screenSize === ScreenSize.Mobile;
|
const isMobile = screenSize === ScreenSize.Mobile;
|
||||||
|
const horseshoe = useHorseshoeEnabled();
|
||||||
|
|
||||||
if (resizable && !isMobile) {
|
if (resizable && !isMobile) {
|
||||||
return <ResizablePageNav>{children}</ResizablePageNav>;
|
return <ResizablePageNav>{children}</ResizablePageNav>;
|
||||||
|
|
@ -60,7 +153,11 @@ export function PageNav({
|
||||||
className={css.PageNav({ size })}
|
className={css.PageNav({ size })}
|
||||||
shrink={isMobile ? 'Yes' : 'No'}
|
shrink={isMobile ? 'Yes' : 'No'}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -70,6 +167,7 @@ export function PageNav({
|
||||||
function ResizablePageNav({ children }: { children: ReactNode }) {
|
function ResizablePageNav({ children }: { children: ReactNode }) {
|
||||||
const navRef = useRef<HTMLDivElement>(null);
|
const navRef = useRef<HTMLDivElement>(null);
|
||||||
const handleRef = useRef<HTMLDivElement>(null);
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
const horseshoe = useHorseshoeEnabled();
|
||||||
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
|
const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom);
|
||||||
const [vw, setVw] = useState<number>(
|
const [vw, setVw] = useState<number>(
|
||||||
typeof window !== 'undefined' ? window.innerWidth : 1280
|
typeof window !== 'undefined' ? window.innerWidth : 1280
|
||||||
|
|
@ -182,7 +280,11 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={navRef} className={css.PageNavResizable} style={{ width }}>
|
<Box ref={navRef} className={css.PageNavResizable} style={{ width }}>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
{canResize && (
|
{canResize && (
|
||||||
|
|
@ -196,6 +298,23 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
||||||
aria-label="Resize sidebar"
|
aria-label="Resize sidebar"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={css.PageNavResizeHandle}
|
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-dragging={dragging || undefined}
|
||||||
data-at-min={atMin || undefined}
|
data-at-min={atMin || undefined}
|
||||||
data-at-max={atMax || undefined}
|
data-at-max={atMax || undefined}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
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';
|
||||||
|
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||||
|
|
||||||
export const PageNavResizable = style({
|
export const PageNavResizable = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
@ -12,6 +13,9 @@ export const PageNavResizeHandle = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
// Native default — sits across the (original) `<Line>` separator
|
||||||
|
// between page-nav and content. Web shifts the handle into the
|
||||||
|
// horseshoe void via an inline style override in `ResizablePageNav`.
|
||||||
right: -3,
|
right: -3,
|
||||||
width: 7,
|
width: 7,
|
||||||
cursor: 'col-resize',
|
cursor: 'col-resize',
|
||||||
|
|
@ -84,6 +88,24 @@ export const PageNav = recipe({
|
||||||
});
|
});
|
||||||
export type PageNavVariants = RecipeVariants<typeof PageNav>;
|
export type PageNavVariants = RecipeVariants<typeof PageNav>;
|
||||||
|
|
||||||
|
// Web-only horseshoe shell wrapping every page-nav's inner column.
|
||||||
|
// `overflow:hidden + border-radius` clips the nav's content into a
|
||||||
|
// shape with rounded TR and BR corners; the wrapper PageRoot puts
|
||||||
|
// behind the nav paints `#090909` at those same corners via background-
|
||||||
|
// image, so the carved area reads as the horseshoe void. An explicit
|
||||||
|
// `Background.Container` bg is required: folds `<Header>` is fully
|
||||||
|
// transparent (it only sets color/border), and some routes (Bots,
|
||||||
|
// Channels) don't paint anything at the bottom — without this bg the
|
||||||
|
// PageRoot void would bleed through the entire header / footer instead
|
||||||
|
// of just the carved corner. Applied conditionally in `PageNav` /
|
||||||
|
// `ResizablePageNav` below; native skips it entirely.
|
||||||
|
export const PageNavInnerWebHorseshoe = style({
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderTopRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||||
|
borderBottomRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
});
|
||||||
|
|
||||||
export const PageNavHeader = recipe({
|
export const PageNavHeader = recipe({
|
||||||
base: {
|
base: {
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { toRem } from 'folds';
|
import { toRem } from 'folds';
|
||||||
|
import {
|
||||||
|
VOJO_HORSESHOE_VOID_COLOR,
|
||||||
|
VOJO_HORSESHOE_GAP_PX,
|
||||||
|
VOJO_HORSESHOE_RADIUS_PX,
|
||||||
|
} from '../styles/horseshoe';
|
||||||
|
|
||||||
// Color of the «void» between the app shell and the bottom call
|
// Color of the «void» between the app shell and the bottom call rail —
|
||||||
// rail — fixed in design, not theme-driven. Painted only when the
|
// shared across every horseshoe surface, see `styles/horseshoe.ts`.
|
||||||
// call rail is mounted, so the rest of the app keeps its normal page
|
// Painted only when the call rail is mounted, so the rest of the app
|
||||||
// background.
|
// keeps its normal page background.
|
||||||
const SURFACE_GAP_COLOR = '#090909';
|
const SURFACE_GAP_COLOR = VOJO_HORSESHOE_VOID_COLOR;
|
||||||
const HORSESHOE_RADIUS = toRem(32);
|
const HORSESHOE_RADIUS = toRem(VOJO_HORSESHOE_RADIUS_PX);
|
||||||
const HORSESHOE_GAP = toRem(12);
|
const HORSESHOE_GAP = toRem(VOJO_HORSESHOE_GAP_PX);
|
||||||
|
|
||||||
// Outer flex column hosting app shell + bottom call rail.
|
// Outer flex column hosting app shell + bottom call rail.
|
||||||
// `min-height: 0` is required for nested scroll containers inside
|
// `min-height: 0` is required for nested scroll containers inside
|
||||||
|
|
|
||||||
24
src/app/styles/horseshoe.ts
Normal file
24
src/app/styles/horseshoe.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Shared constants for the Vojo "horseshoe" design language.
|
||||||
|
//
|
||||||
|
// The void colour is fixed in design and not theme-driven — the same
|
||||||
|
// #090909 is painted between every pair of horseshoe surfaces (top
|
||||||
|
// user-card silhouette, bottom call rail, settings/profile rail, etc.)
|
||||||
|
// so the seam reads as the same "near-black" no matter where it shows
|
||||||
|
// up. Originally introduced with the bottom call rail (commit 7054ca2 /
|
||||||
|
// `HorseshoeContainer.css.ts`); centralised here so consumers don't
|
||||||
|
// duplicate the literal and the value can be tuned in one place.
|
||||||
|
export const VOJO_HORSESHOE_VOID_COLOR = '#090909';
|
||||||
|
|
||||||
|
// Width (and height where vertical) of the void gap separating two
|
||||||
|
// horseshoe surfaces — kept identical between the bottom call rail
|
||||||
|
// and the page-nav <-> chat split so all seams in the app read with
|
||||||
|
// the same thickness. Pixels, intended for `toRem(...)` at the use
|
||||||
|
// site.
|
||||||
|
export const VOJO_HORSESHOE_GAP_PX = 12;
|
||||||
|
|
||||||
|
// Corner radius of every horseshoe surface — also pixels, applied via
|
||||||
|
// `toRem(...)` at the use site. Centralised so the page-nav corners,
|
||||||
|
// the chat-panel corners, and the bottom call-rail corners stay in
|
||||||
|
// lockstep, including the bg-image squares painted into those corners
|
||||||
|
// (which must match the radius exactly to avoid bleed-out).
|
||||||
|
export const VOJO_HORSESHOE_RADIUS_PX = 32;
|
||||||
Loading…
Add table
Reference in a new issue