Skip to content

Commit 0948622

Browse files
committed
fix: static header scrolling and improve AppShell layout orchestration
- Update AppShell to keep 'static' headers inside the main scrollable SafeArea. - Automatically pass forceSafeAreaTop=true to 'fixed/sticky/reveal' headers. - Refactor Header to conditionally handle top safe area based on behavior and props. - This ensures static headers scroll away correctly while fixed ones stay pinned.
1 parent 9ae7a68 commit 0948622

3 files changed

Lines changed: 28 additions & 9 deletions

File tree

packages/react/src/AppShell.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,47 @@ function AppShellInner({ safeArea = false, className, children }: AppShellProps)
1616
);
1717
}
1818

19-
// When safeArea is true, we want to ensure the Header can be sticky correctly.
20-
// Sticky elements don't work well when their parent has padding-top.
21-
// So we split Header from other children.
2219
let header: ReactNode = null;
20+
let headerBehavior: string = "fixed";
2321
const otherChildren: ReactNode[] = [];
2422

2523
Children.forEach(children, (child) => {
2624
if (isValidElement(child)) {
27-
// Check for Header component by type or displayName
2825
const isHeader = child.type === Header ||
2926
(child.type as any).displayName === "Header" ||
3027
(child.type as any).name === "Header";
3128

3229
if (isHeader) {
3330
header = child;
31+
headerBehavior = (child.props as any).behavior || "fixed";
3432
} else {
3533
otherChildren.push(child);
3634
}
3735
}
3836
});
3937

38+
const isStatic = headerBehavior === "static";
39+
40+
// For static behavior, the header MUST be part of the same scroll container
41+
// as the content to scroll away.
42+
if (isStatic) {
43+
return (
44+
<SafeArea edges={["top", "bottom"]} className={cn("flex min-h-dvh flex-col relative", className)}>
45+
{header}
46+
<div className="flex flex-col flex-1">
47+
{otherChildren}
48+
</div>
49+
</SafeArea>
50+
);
51+
}
52+
53+
// For fixed/sticky/reveal behaviors, the header handles its own top safe area
54+
// and stays pinned or manages its own animation.
4055
return (
4156
<div className={cn("flex min-h-dvh flex-col relative", className)}>
42-
{header}
57+
{header && isValidElement(header)
58+
? Object.assign({}, header, { props: { ...header.props, forceSafeAreaTop: true } })
59+
: header}
4360
<SafeArea edges={["bottom"]} className="flex flex-col flex-1">
4461
{otherChildren}
4562
</SafeArea>

packages/react/src/Header.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const Header = memo(function Header({
6262
speed = "normal",
6363
mobileMenu,
6464
onVisibilityChange,
65+
forceSafeAreaTop = false,
6566
className,
6667
}: HeaderProps) {
6768
const { motion, AnimatePresence } = useMotion();
@@ -119,7 +120,7 @@ export const Header = memo(function Header({
119120
const [isPastThreshold, setIsPastThreshold] = useState(false);
120121

121122
useEffect(() => {
122-
if (behavior === "static" || behavior === "fixed") return;
123+
if (behavior === "static" || behavior === "fixed" || behavior === "sticky") return;
123124

124125
const onScroll = () => {
125126
setIsPastThreshold(window.scrollY > threshold + 10);
@@ -134,7 +135,7 @@ export const Header = memo(function Header({
134135
hasRevealEffect && scrollDirection === "up" && isPastThreshold;
135136

136137
useEffect(() => {
137-
onVisibilityChange?.(behavior === "fixed" || !hasRevealEffect || isOverlayVisible);
138+
onVisibilityChange?.(behavior === "fixed" || behavior === "sticky" || !hasRevealEffect || isOverlayVisible);
138139
}, [isOverlayVisible, behavior, hasRevealEffect, onVisibilityChange]);
139140

140141
const shouldShowInOverlay = useCallback(
@@ -252,7 +253,7 @@ export const Header = memo(function Header({
252253

253254
const renderContent = () => (
254255
<HeaderProvider value={{ theme }}>
255-
{renderNavRow(behavior !== "static" && behavior !== "fixed")}
256+
{renderNavRow(behavior !== "static" && behavior !== "fixed" && behavior !== "sticky")}
256257
{renderContextRow()}
257258
{renderSearchRow()}
258259
{renderMobileMenuPanel()}
@@ -291,7 +292,7 @@ export const Header = memo(function Header({
291292
t.wrapper,
292293
className
293294
)}
294-
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
295+
style={{ paddingTop: forceSafeAreaTop ? "env(safe-area-inset-top, 0px)" : undefined }}
295296
>
296297
{renderContent()}
297298
</header>

packages/react/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export interface HeaderProps {
4646
speed?: AnimationSpeed;
4747
mobileMenu?: ReactNode;
4848
onVisibilityChange?: (visible: boolean) => void;
49+
forceSafeAreaTop?: boolean;
4950
className?: string;
5051
}
5152

0 commit comments

Comments
 (0)