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
98f4d2a8f5
commit
363bd9d361
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 { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { HorseshoeEnabledContext } from './page';
|
||||
|
||||
type Modal500Props = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -20,7 +21,14 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
|||
}}
|
||||
>
|
||||
<Modal size="500" variant="Background">
|
||||
{children}
|
||||
{/* 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}
|
||||
</HorseshoeEnabledContext.Provider>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import React, {
|
|||
ComponentProps,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} 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 classNames from 'classnames';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
|
|
@ -18,6 +20,27 @@ import {
|
|||
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;
|
||||
|
|
@ -26,13 +49,82 @@ type PageRootProps = {
|
|||
|
||||
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 — 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 (
|
||||
<Box grow="Yes" className={ContainerColor({ variant: 'Background' })}>
|
||||
{nav}
|
||||
{screenSize !== ScreenSize.Mobile && (
|
||||
<Line variant="Background" size="300" direction="Vertical" />
|
||||
)}
|
||||
{!isMobile && <Line variant="Background" size="300" direction="Vertical" />}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -49,6 +141,7 @@ export function PageNav({
|
|||
}: ClientDrawerLayoutProps & css.PageNavVariants) {
|
||||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
const horseshoe = useHorseshoeEnabled();
|
||||
|
||||
if (resizable && !isMobile) {
|
||||
return <ResizablePageNav>{children}</ResizablePageNav>;
|
||||
|
|
@ -60,7 +153,11 @@ export function PageNav({
|
|||
className={css.PageNav({ size })}
|
||||
shrink={isMobile ? 'Yes' : 'No'}
|
||||
>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -70,6 +167,7 @@ export function PageNav({
|
|||
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
|
||||
|
|
@ -182,7 +280,11 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
|||
|
||||
return (
|
||||
<Box ref={navRef} className={css.PageNavResizable} style={{ width }}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
className={horseshoe ? css.PageNavInnerWebHorseshoe : undefined}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
{canResize && (
|
||||
|
|
@ -196,6 +298,23 @@ function ResizablePageNav({ children }: { children: ReactNode }) {
|
|||
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}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||
|
||||
export const PageNavResizable = style({
|
||||
position: 'relative',
|
||||
|
|
@ -12,6 +13,9 @@ export const PageNavResizeHandle = style({
|
|||
position: 'absolute',
|
||||
top: 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,
|
||||
width: 7,
|
||||
cursor: 'col-resize',
|
||||
|
|
@ -84,6 +88,24 @@ export const PageNav = recipe({
|
|||
});
|
||||
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({
|
||||
base: {
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
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
|
||||
// rail — fixed in design, not theme-driven. Painted only when the
|
||||
// call rail is mounted, so the rest of the app keeps its normal page
|
||||
// background.
|
||||
const SURFACE_GAP_COLOR = '#090909';
|
||||
const HORSESHOE_RADIUS = toRem(32);
|
||||
const HORSESHOE_GAP = toRem(12);
|
||||
// Color of the «void» between the app shell and the bottom call rail —
|
||||
// shared across every horseshoe surface, see `styles/horseshoe.ts`.
|
||||
// Painted only when the call rail is mounted, so the rest of the app
|
||||
// keeps its normal page background.
|
||||
const SURFACE_GAP_COLOR = VOJO_HORSESHOE_VOID_COLOR;
|
||||
const HORSESHOE_RADIUS = toRem(VOJO_HORSESHOE_RADIUS_PX);
|
||||
const HORSESHOE_GAP = toRem(VOJO_HORSESHOE_GAP_PX);
|
||||
|
||||
// Outer flex column hosting app shell + bottom call rail.
|
||||
// `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