refactor: Move page header outside content container using context

Problem:
- Page header inside scrollable content container
- Complex sticky positioning logic
- Different behavior in different modes

Better Architecture:
Move page header to same level as submenu, outside scroll container

Structure:
<main flex flex-col>
  <SubmenuBar sticky>           ← Sticky outside scroll
  <PageHeader sticky>            ← Sticky outside scroll 
  <div overflow-auto>            ← Only content scrolls
    <AppRoutes />

Implementation:
1. PageHeaderContext - Global state for page header
   - title: string
   - action: ReactNode (e.g., Save button)
   - setPageHeader() / clearPageHeader()

2. PageHeader Component - Renders at app level
   - Positioned after submenu
   - Sticky top-[49px] (below submenu)
   - Boxed layout (max-w-5xl, centered)
   - Consumes context

3. SettingsLayout - Sets header via context
   - useEffect to set/clear header
   - No inline sticky header
   - Cleaner component

Benefits:
 Page header outside scroll container
 Sticky works consistently (no mode detection)
 Submenu layout preserved (justify-start)
 Page header uses page layout (boxed, centered)
 Separation of concerns
 Reusable for any page that needs sticky header

Layout Hierarchy:
┌─────────────────────────────────────┐
│ <main flex flex-col>                │
│   ┌─────────────────────────────┐   │
│   │ SubmenuBar (sticky)         │   │ ← justify-start
│   ├─────────────────────────────┤   │
│   │ PageHeader (sticky)         │   │ ← max-w-5xl centered
│   ├─────────────────────────────┤   │
│   │ <div overflow-auto>         │   │
│   │   Content (scrolls)         │   │
│   └─────────────────────────────┘   │
└─────────────────────────────────────┘

Files Created:
- PageHeaderContext.tsx: Context provider
- PageHeader.tsx: Header component

Files Modified:
- App.tsx: Added PageHeader after submenu in all layouts
- SettingsLayout.tsx: Use context instead of inline header

Result:
 Clean architecture
 Consistent sticky behavior
 No mode-specific logic
 Reusable pattern
This commit is contained in:
dwindown
2025-11-06 15:34:00 +07:00
parent 99748ca202
commit 2ec76c7dec
4 changed files with 98 additions and 28 deletions

View File

@@ -29,6 +29,8 @@ import { useCommandStore } from "@/lib/useCommandStore";
import SubmenuBar from './components/nav/SubmenuBar';
import DashboardSubmenuBar from './components/nav/DashboardSubmenuBar';
import { DashboardProvider } from '@/contexts/DashboardContext';
import { PageHeaderProvider } from '@/contexts/PageHeaderContext';
import { PageHeader } from '@/components/PageHeader';
import { useActiveSection } from '@/hooks/useActiveSection';
import { NAV_TREE_VERSION } from '@/nav/tree';
import { __ } from '@/lib/i18n';
@@ -420,6 +422,7 @@ function Shell() {
) : (
<SubmenuBar items={main.children} fullscreen={true} />
)}
<PageHeader />
<div className="flex-1 overflow-auto p-4">
<AppRoutes />
</div>
@@ -434,6 +437,7 @@ function Shell() {
<SubmenuBar items={main.children} fullscreen={true} />
)}
<main className="flex-1 flex flex-col min-h-0">
<PageHeader />
<div className="flex-1 overflow-auto p-4">
<AppRoutes />
</div>
@@ -448,6 +452,7 @@ function Shell() {
) : (
<SubmenuBar items={main.children} fullscreen={false} />
)}
<PageHeader />
<main className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-auto p-4">
<AppRoutes />
@@ -505,9 +510,11 @@ function AuthWrapper() {
}
return (
<PageHeaderProvider>
<DashboardProvider>
<Shell />
</DashboardProvider>
</PageHeaderProvider>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { usePageHeader } from '@/contexts/PageHeaderContext';
export function PageHeader() {
const { title, action } = usePageHeader();
if (!title) return null;
return (
<div className="sticky top-[49px] z-10 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">{title}</h1>
</div>
{action && <div>{action}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface PageHeaderContextType {
title: string | null;
action: ReactNode | null;
setPageHeader: (title: string | null, action?: ReactNode) => void;
clearPageHeader: () => void;
}
const PageHeaderContext = createContext<PageHeaderContextType | undefined>(undefined);
export function PageHeaderProvider({ children }: { children: ReactNode }) {
const [title, setTitle] = useState<string | null>(null);
const [action, setAction] = useState<ReactNode | null>(null);
const setPageHeader = (newTitle: string | null, newAction?: ReactNode) => {
setTitle(newTitle);
setAction(newAction || null);
};
const clearPageHeader = () => {
setTitle(null);
setAction(null);
};
return (
<PageHeaderContext.Provider value={{ title, action, setPageHeader, clearPageHeader }}>
{children}
</PageHeaderContext.Provider>
);
}
export function usePageHeader() {
const context = useContext(PageHeaderContext);
if (!context) {
throw new Error('usePageHeader must be used within PageHeaderProvider');
}
return context;
}

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { usePageHeader } from '@/contexts/PageHeaderContext';
interface SettingsLayoutProps {
title: string;
@@ -22,6 +23,7 @@ export function SettingsLayout({
action,
}: SettingsLayoutProps) {
const [isSaving, setIsSaving] = useState(false);
const { setPageHeader, clearPageHeader } = usePageHeader();
const handleSave = async () => {
if (!onSave) return;
@@ -33,15 +35,11 @@ export function SettingsLayout({
}
};
return (
<div className="space-y-6">
{/* Sticky Header with Save Button - Edge to edge */}
{onSave && (
<div className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 -mx-4 px-4 mb-6">
<div className="container px-0 max-w-5xl mx-auto py-3 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">{title}</h1>
</div>
// Set page header when component mounts
useEffect(() => {
if (onSave) {
setPageHeader(
title,
<Button
onClick={handleSave}
disabled={isSaving || isLoading}
@@ -56,9 +54,16 @@ export function SettingsLayout({
saveLabel
)}
</Button>
</div>
</div>
)}
);
} else {
clearPageHeader();
}
return () => clearPageHeader();
}, [title, onSave, isSaving, isLoading, saveLabel]);
return (
<div className="space-y-6">
{/* Content */}
<div className="container px-0 max-w-5xl mx-auto">