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:
@@ -29,6 +29,8 @@ import { useCommandStore } from "@/lib/useCommandStore";
|
|||||||
import SubmenuBar from './components/nav/SubmenuBar';
|
import SubmenuBar from './components/nav/SubmenuBar';
|
||||||
import DashboardSubmenuBar from './components/nav/DashboardSubmenuBar';
|
import DashboardSubmenuBar from './components/nav/DashboardSubmenuBar';
|
||||||
import { DashboardProvider } from '@/contexts/DashboardContext';
|
import { DashboardProvider } from '@/contexts/DashboardContext';
|
||||||
|
import { PageHeaderProvider } from '@/contexts/PageHeaderContext';
|
||||||
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { useActiveSection } from '@/hooks/useActiveSection';
|
import { useActiveSection } from '@/hooks/useActiveSection';
|
||||||
import { NAV_TREE_VERSION } from '@/nav/tree';
|
import { NAV_TREE_VERSION } from '@/nav/tree';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
@@ -420,6 +422,7 @@ function Shell() {
|
|||||||
) : (
|
) : (
|
||||||
<SubmenuBar items={main.children} fullscreen={true} />
|
<SubmenuBar items={main.children} fullscreen={true} />
|
||||||
)}
|
)}
|
||||||
|
<PageHeader />
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</div>
|
</div>
|
||||||
@@ -434,6 +437,7 @@ function Shell() {
|
|||||||
<SubmenuBar items={main.children} fullscreen={true} />
|
<SubmenuBar items={main.children} fullscreen={true} />
|
||||||
)}
|
)}
|
||||||
<main className="flex-1 flex flex-col min-h-0">
|
<main className="flex-1 flex flex-col min-h-0">
|
||||||
|
<PageHeader />
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</div>
|
</div>
|
||||||
@@ -448,6 +452,7 @@ function Shell() {
|
|||||||
) : (
|
) : (
|
||||||
<SubmenuBar items={main.children} fullscreen={false} />
|
<SubmenuBar items={main.children} fullscreen={false} />
|
||||||
)}
|
)}
|
||||||
|
<PageHeader />
|
||||||
<main className="flex-1 flex flex-col min-h-0">
|
<main className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex-1 overflow-auto p-4">
|
<div className="flex-1 overflow-auto p-4">
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
@@ -505,9 +510,11 @@ function AuthWrapper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PageHeaderProvider>
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
<Shell />
|
<Shell />
|
||||||
</DashboardProvider>
|
</DashboardProvider>
|
||||||
|
</PageHeaderProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
admin-spa/src/components/PageHeader.tsx
Normal file
19
admin-spa/src/components/PageHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
admin-spa/src/contexts/PageHeaderContext.tsx
Normal file
39
admin-spa/src/contexts/PageHeaderContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,6 +23,7 @@ export function SettingsLayout({
|
|||||||
action,
|
action,
|
||||||
}: SettingsLayoutProps) {
|
}: SettingsLayoutProps) {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!onSave) return;
|
if (!onSave) return;
|
||||||
@@ -33,15 +35,11 @@ export function SettingsLayout({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Set page header when component mounts
|
||||||
<div className="space-y-6">
|
useEffect(() => {
|
||||||
{/* Sticky Header with Save Button - Edge to edge */}
|
if (onSave) {
|
||||||
{onSave && (
|
setPageHeader(
|
||||||
<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">
|
title,
|
||||||
<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>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving || isLoading}
|
disabled={isSaving || isLoading}
|
||||||
@@ -56,9 +54,16 @@ export function SettingsLayout({
|
|||||||
saveLabel
|
saveLabel
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
);
|
||||||
</div>
|
} else {
|
||||||
)}
|
clearPageHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => clearPageHeader();
|
||||||
|
}, [title, onSave, isSaving, isLoading, saveLabel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="container px-0 max-w-5xl mx-auto">
|
<div className="container px-0 max-w-5xl mx-auto">
|
||||||
|
|||||||
Reference in New Issue
Block a user