refactor(layout): adopt header-slot page container across pages
This commit is contained in:
@@ -7,11 +7,61 @@ import {
|
||||
import { useOverlayScroller } from '@gpustack/core-ui';
|
||||
import { Divider } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { useContext, useEffect, useRef } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import pageBoxCss from './styles/page-box.less';
|
||||
|
||||
const paddingInlinePageContainerContent = 24;
|
||||
|
||||
type HeaderSlotContextValue = {
|
||||
leftEl: HTMLElement | null;
|
||||
rightEl: HTMLElement | null;
|
||||
setContentStyle: (style: React.CSSProperties | undefined) => () => void;
|
||||
};
|
||||
|
||||
const HeaderSlotContext = createContext<HeaderSlotContextValue | null>(null);
|
||||
|
||||
// Pages render <HeaderLeft> / <HeaderRight> as part of their JSX; the children
|
||||
// are portaled into the layout-owned PageContainerInner header bar so the
|
||||
// shell never unmounts between routes. Visibility of the default title /
|
||||
// right divider is driven by CSS (`:empty` / `:not(:empty)`) on the portal
|
||||
// target, so they react synchronously to DOM mutations — no React state, no
|
||||
// re-renders, no inter-frame flicker.
|
||||
export const HeaderLeft: React.FC<{ children: React.ReactNode }> = ({
|
||||
children
|
||||
}) => {
|
||||
const ctx = useContext(HeaderSlotContext);
|
||||
return ctx?.leftEl ? createPortal(children, ctx.leftEl) : null;
|
||||
};
|
||||
|
||||
export const HeaderRight: React.FC<{ children: React.ReactNode }> = ({
|
||||
children
|
||||
}) => {
|
||||
const ctx = useContext(HeaderSlotContext);
|
||||
return ctx?.rightEl ? createPortal(children, ctx.rightEl) : null;
|
||||
};
|
||||
|
||||
// Pages that need to override the layout-owned content wrapper style
|
||||
// (e.g. playground pages that want zero padding) call this hook in render.
|
||||
// Applied synchronously via useLayoutEffect to avoid first-paint flicker.
|
||||
export const usePageContentStyle = (style?: React.CSSProperties): void => {
|
||||
const ctx = useContext(HeaderSlotContext);
|
||||
const stable = JSON.stringify(style ?? null);
|
||||
useLayoutEffect(() => {
|
||||
if (!ctx) return;
|
||||
return ctx.setContentStyle(style);
|
||||
}, [stable, ctx?.setContentStyle]);
|
||||
};
|
||||
|
||||
export const PageContainerInner: React.FC<
|
||||
PageContainerProps & {
|
||||
leftContent?: React.ReactNode;
|
||||
@@ -26,6 +76,11 @@ export const PageContainerInner: React.FC<
|
||||
});
|
||||
const pageContext = useContext(RouteContext);
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [leftEl, setLeftEl] = useState<HTMLDivElement | null>(null);
|
||||
const [rightEl, setRightEl] = useState<HTMLDivElement | null>(null);
|
||||
const [contentStyleOverride, setContentStyleOverride] = useState<
|
||||
React.CSSProperties | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentWrapperRef.current) {
|
||||
@@ -34,43 +89,67 @@ export const PageContainerInner: React.FC<
|
||||
}
|
||||
}, [initialize, contentWrapperRef]);
|
||||
|
||||
const setContentStyle = useCallback(
|
||||
(style: React.CSSProperties | undefined) => {
|
||||
setContentStyleOverride(style);
|
||||
return () => setContentStyleOverride(undefined);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const slotValue = useMemo<HeaderSlotContextValue>(
|
||||
() => ({
|
||||
leftEl,
|
||||
rightEl,
|
||||
setContentStyle
|
||||
}),
|
||||
[leftEl, rightEl, setContentStyle]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={pageBoxCss.containerWrapper}>
|
||||
<PageContainer
|
||||
{...rest}
|
||||
fixedHeader={false}
|
||||
title={false}
|
||||
pageHeaderRender={false}
|
||||
style={{
|
||||
flex: 1
|
||||
}}
|
||||
token={{
|
||||
paddingInlinePageContainerContent: paddingInlinePageContainerContent
|
||||
}}
|
||||
>
|
||||
<div className={pageBoxCss.title}>
|
||||
<div className={pageBoxCss.left}>
|
||||
{leftContent || pageContext.title}
|
||||
</div>
|
||||
<div className={pageBoxCss.right}>
|
||||
{rightContent && (
|
||||
<div>
|
||||
{rightContent}
|
||||
<Divider orientation="vertical" style={{ margin: '0 16px' }} />
|
||||
</div>
|
||||
)}
|
||||
<ExtraContent />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(pageBoxCss.contentWrapper)}
|
||||
style={styles?.containerWrapper}
|
||||
ref={contentWrapperRef}
|
||||
<HeaderSlotContext.Provider value={slotValue}>
|
||||
<div className={pageBoxCss.containerWrapper}>
|
||||
<PageContainer
|
||||
{...rest}
|
||||
fixedHeader={false}
|
||||
title={false}
|
||||
pageHeaderRender={false}
|
||||
style={{
|
||||
flex: 1
|
||||
}}
|
||||
token={{
|
||||
paddingInlinePageContainerContent: paddingInlinePageContainerContent
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
<div className={pageBoxCss.title}>
|
||||
<div className={pageBoxCss.left}>
|
||||
<div ref={setLeftEl} className={pageBoxCss.leftSlot} />
|
||||
{leftContent}
|
||||
<span className={pageBoxCss.defaultTitle}>
|
||||
{pageContext.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className={pageBoxCss.right}>
|
||||
<div ref={setRightEl} className={pageBoxCss.rightSlot} />
|
||||
{rightContent}
|
||||
<Divider
|
||||
className={pageBoxCss.divider}
|
||||
orientation="vertical"
|
||||
style={{ margin: '0 16px' }}
|
||||
/>
|
||||
<ExtraContent />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(pageBoxCss.contentWrapper)}
|
||||
style={{ ...styles?.containerWrapper, ...contentStyleOverride }}
|
||||
ref={contentWrapperRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
</HeaderSlotContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -70,14 +70,44 @@
|
||||
height: 56px;
|
||||
border-bottom: 1px solid var(--ant-color-split);
|
||||
|
||||
// `.left` claims all the space between the title's left edge and `.right`.
|
||||
// Because the container width is fixed regardless of inner content, the
|
||||
// right side never shifts during route transitions and the content inside
|
||||
// .left is anchored to the left edge — no horizontal jitter.
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.leftSlot,
|
||||
.rightSlot {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.defaultTitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Portal-driven visibility: when a page's HeaderLeft / HeaderRight
|
||||
// portal target has children, hide the default title / show the divider.
|
||||
// Using `:empty` (rather than React state) keeps visibility synchronous
|
||||
// with the DOM mutation, so there is no inter-frame flicker on route
|
||||
// transitions.
|
||||
.leftSlot:not(:empty) ~ .defaultTitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rightSlot:empty ~ .divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user