Merge pull request 'core/shell' (#18) from core/shell into main
Reviewed-on: eigen/fe-monorepo-template#18
This commit is contained in:
@@ -6,6 +6,7 @@ import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/component
|
|||||||
const AuthModule = lazy(() => import('./auth'));
|
const AuthModule = lazy(() => import('./auth'));
|
||||||
const AppModule = lazy(() => import('./modules'));
|
const AppModule = lazy(() => import('./modules'));
|
||||||
const ShowcaseView = lazy(() => import('./showcase/showcase-view'));
|
const ShowcaseView = lazy(() => import('./showcase/showcase-view'));
|
||||||
|
const ShellDemo = lazy(() => import('./showcase/shell-demo'));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [colorScheme, setColorScheme] = useState<ColorSchemeType>('light');
|
const [colorScheme, setColorScheme] = useState<ColorSchemeType>('light');
|
||||||
@@ -29,6 +30,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/shell-demo" element={<ShellDemo />} />
|
||||||
<Route path="/404" element={<NotFound />} />
|
<Route path="/404" element={<NotFound />} />
|
||||||
<Route path="/403" element={<Forbidden />} />
|
<Route path="/403" element={<Forbidden />} />
|
||||||
<Route path="/maintenance" element={<Maintenance />} />
|
<Route path="/maintenance" element={<Maintenance />} />
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { useState, useMemo, ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Paper,
|
||||||
|
Switch,
|
||||||
|
Select,
|
||||||
|
Burger
|
||||||
|
} from '@repo/ui/components';
|
||||||
|
import {
|
||||||
|
CoreAppShell,
|
||||||
|
CorePageContainer,
|
||||||
|
CoreAppShellConfig,
|
||||||
|
LayoutVariant,
|
||||||
|
DesktopCollapseVariant,
|
||||||
|
useCoreAppShell
|
||||||
|
} from '@repo/ui/components';
|
||||||
|
import { Home, BarChart2, Settings as SettingsIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
// Sub-component to test hook methods
|
||||||
|
function LayoutControls() {
|
||||||
|
const { toggleDesktop, toggleMobile, sidebarVariant, setSidebarVariant, toggleAside, toggleNavbarPanel } = useCoreAppShell();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group mb="md">
|
||||||
|
<Button onClick={toggleDesktop} variant="default" size="xs">Toggle Desktop Sidebar</Button>
|
||||||
|
<Button onClick={toggleMobile} variant="default" size="xs" hiddenFrom="sm">Toggle Mobile Sidebar</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setSidebarVariant(sidebarVariant === 'expanded' ? 'mini' : 'expanded')}
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Toggle Sidebar Variant (Current: {sidebarVariant})
|
||||||
|
</Button>
|
||||||
|
<Button onClick={toggleNavbarPanel} variant="light" color="grape" size="xs">
|
||||||
|
Toggle Secondary Panel (Double Sidebar)
|
||||||
|
</Button>
|
||||||
|
<Button onClick={toggleAside} variant="filled" color="cyan" size="xs">
|
||||||
|
Toggle Aside via Context
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MockHeader() {
|
||||||
|
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
||||||
|
return (
|
||||||
|
<Group h="100%" px="md" justify="space-between" bg="green.1" c="green.9" style={{ borderBottom: '1px solid var(--mantine-color-green-3)' }}>
|
||||||
|
<Group>
|
||||||
|
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||||
|
<Text fw={700} size="lg">Mock Header (bg="green.1")</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MockMobileDrawer() {
|
||||||
|
return (
|
||||||
|
<Box p="md" h="100%" bg="yellow.1" c="yellow.9">
|
||||||
|
<Text fw={700} mb="sm">Mock Mobile Drawer (bg="yellow.1")</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button variant="light" color="yellow" justify="flex-start" fullWidth>Mobile Dashboard</Button>
|
||||||
|
<Button variant="light" color="yellow" justify="flex-start" fullWidth>Mobile Settings</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({ title, description, control }: { title: string; description: string; control: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={500}>{title}</Text>
|
||||||
|
<Text c="dimmed" size="sm">{description}</Text>
|
||||||
|
</Stack>
|
||||||
|
<Box>{control}</Box>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShellDemo() {
|
||||||
|
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('header-first');
|
||||||
|
const [collapseVariant, setCollapseVariant] = useState<DesktopCollapseVariant>('hide');
|
||||||
|
const [withUtilityBar, setWithUtilityBar] = useState(true);
|
||||||
|
const [withAside, setWithAside] = useState(true);
|
||||||
|
const [withFooter, setWithFooter] = useState(true);
|
||||||
|
const [withDoubleSidebar, setWithDoubleSidebar] = useState(true);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const liveConfig = useMemo(() => {
|
||||||
|
return {
|
||||||
|
variant: layoutVariant,
|
||||||
|
features: {
|
||||||
|
desktopCollapseVariant: collapseVariant,
|
||||||
|
withUtilityBar: withUtilityBar ? undefined : false,
|
||||||
|
withAside: withAside ? undefined : false,
|
||||||
|
withFooter: withFooter ? undefined : false,
|
||||||
|
withDoubleSidebar,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [layoutVariant, collapseVariant, withUtilityBar, withAside, withFooter, withDoubleSidebar]);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(liveConfig, null, 2));
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: CoreAppShellConfig = {
|
||||||
|
variant: layoutVariant,
|
||||||
|
features: {
|
||||||
|
desktopCollapseVariant: collapseVariant,
|
||||||
|
withUtilityBar: withUtilityBar ? undefined : false,
|
||||||
|
withAside: withAside ? undefined : false,
|
||||||
|
withFooter: withFooter ? undefined : false,
|
||||||
|
withDoubleSidebar,
|
||||||
|
persistState: false, // Don't persist for the demo to avoid confusing other showcases
|
||||||
|
disabled: false, // Allow nested render overrides if needed
|
||||||
|
zIndex: 100,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CoreAppShell
|
||||||
|
config={config}
|
||||||
|
|
||||||
|
slots={{
|
||||||
|
utilityBar: (
|
||||||
|
<Group h="100%" px="md" justify="flex-end" bg="blue.1" c="blue.9">
|
||||||
|
<Text size="xs" fw={600}>Mock Utility Bar (bg="blue.1")</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
header: <MockHeader />,
|
||||||
|
sidebarMobile: <MockMobileDrawer />,
|
||||||
|
sidebar: !withDoubleSidebar ? (
|
||||||
|
<Box p="md" h="100%" bg="grape.1" c="grape.9" style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}>
|
||||||
|
<Text fw={700} mb="sm">Mock Standard Navbar (bg="grape.1")</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>Dashboard</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>Users</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>Reports</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>Settings</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : undefined,
|
||||||
|
sidebarRail: withDoubleSidebar ? (
|
||||||
|
<Stack align="center" gap="lg" pt="md" h="100%" bg="orange.1" c="orange.9">
|
||||||
|
<Text size="xs" fw={700} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
||||||
|
Mock Rail (bg="orange.1")
|
||||||
|
</Text>
|
||||||
|
<Home size={24} />
|
||||||
|
<BarChart2 size={24} />
|
||||||
|
<SettingsIcon size={24} />
|
||||||
|
</Stack>
|
||||||
|
) : undefined,
|
||||||
|
sidebarPanel: withDoubleSidebar ? (
|
||||||
|
<Box p="md" h="100%" bg="grape.1" c="grape.9" style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}>
|
||||||
|
<Text fw={700} mb="sm">Mock Panel (bg="grape.1")</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>Dashboard</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>Users</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>Reports</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>Settings</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : undefined,
|
||||||
|
aside: (
|
||||||
|
<Box p="md" h="100%" bg="cyan.1" c="cyan.9">
|
||||||
|
<Text fw={700} mb="md">Mock Aside</Text>
|
||||||
|
<Text size="sm">This area could be used for notifications, help text, or contextual settings.</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
footer: (
|
||||||
|
<Group h="100%" px="md" justify="space-between" bg="gray.1" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
|
||||||
|
<Text size="sm" fw={600}>Mock Footer (bg="gray.1")</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
|
||||||
|
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CorePageContainer
|
||||||
|
headerSlot={
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text component="h1" size="xl" fw={700} m={0}>
|
||||||
|
Layout Engine Interactive Demo
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button component={Link} to="/showcase" leftSection={<ArrowLeft size={16} />} variant="default">
|
||||||
|
Back to Showcase
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper withBorder p="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<LayoutControls />
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Layout Variant"
|
||||||
|
description="Switch between standard cloud or SaaS layout styles."
|
||||||
|
control={
|
||||||
|
<Select
|
||||||
|
value={layoutVariant}
|
||||||
|
onChange={(value) => setLayoutVariant((value as LayoutVariant) || 'sidebar-first')}
|
||||||
|
data={[
|
||||||
|
{ label: 'Sidebar First (Alt)', value: 'sidebar-first' },
|
||||||
|
{ label: 'Header First (Default)', value: 'header-first' },
|
||||||
|
{ label: 'Top Nav (Hidden Sidebar)', value: 'top-nav' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Desktop Collapse Strategy"
|
||||||
|
description="Determine if the sidebar shrinks to icons or slides out completely."
|
||||||
|
control={
|
||||||
|
<SegmentedControl
|
||||||
|
value={collapseVariant}
|
||||||
|
onChange={(value) => setCollapseVariant(value as DesktopCollapseVariant)}
|
||||||
|
data={[
|
||||||
|
{ label: 'Hide (Slide Out)', value: 'hide' },
|
||||||
|
{ label: 'Mini (Shrink)', value: 'mini' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Enable Double Sidebar"
|
||||||
|
description="Activate the Google-style rail and contextual panel navigation."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={withDoubleSidebar}
|
||||||
|
onChange={(event) => setWithDoubleSidebar(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Render Utility Bar"
|
||||||
|
description="Show a system-level announcement bar above the main header."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={withUtilityBar}
|
||||||
|
onChange={(event) => setWithUtilityBar(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Render Aside"
|
||||||
|
description="Toggle the right-hand properties or filter panel."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={withAside}
|
||||||
|
onChange={(event) => setWithAside(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Render Footer"
|
||||||
|
description="Toggle the bottom application footer."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={withFooter}
|
||||||
|
onChange={(event) => setWithFooter(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box mt="xl">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Text fw={700} size="lg">Configuration Preview</Text>
|
||||||
|
<Button
|
||||||
|
variant={copied ? 'filled' : 'light'}
|
||||||
|
color={copied ? 'teal' : 'blue'}
|
||||||
|
size="xs"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied to Clipboard!' : 'Copy JSON'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Paper withBorder p="md" bg="dark.8" c="gray.0" style={{ fontFamily: 'monospace', overflowX: 'auto' }}>
|
||||||
|
<pre style={{ margin: 0 }}>{JSON.stringify(liveConfig, null, 2)}</pre>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</CorePageContainer>
|
||||||
|
</CoreAppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ColorSchemeType, DensityType } from '@repo/ui/provider';
|
import { ColorSchemeType, DensityType } from '@repo/ui/provider';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -41,6 +42,7 @@ interface ShowcaseViewProps {
|
|||||||
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
|
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Mock data for the table
|
// Mock data for the table
|
||||||
const tableData = [
|
const tableData = [
|
||||||
@@ -65,6 +67,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
return 'Global Event Bus Synchronization';
|
return 'Global Event Bus Synchronization';
|
||||||
case 'hardware':
|
case 'hardware':
|
||||||
return 'Hardware Integration & Printers';
|
return 'Hardware Integration & Printers';
|
||||||
|
case 'layout-engine':
|
||||||
|
return 'Core Layout Engine & Variants';
|
||||||
default:
|
default:
|
||||||
return 'Architecture Showcase';
|
return 'Architecture Showcase';
|
||||||
}
|
}
|
||||||
@@ -76,7 +80,13 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
placement="left"
|
placement="left"
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={(val) => {
|
||||||
|
if (val === 'layout-engine') {
|
||||||
|
navigate('/shell-demo');
|
||||||
|
} else {
|
||||||
|
setActiveTab(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant="pills"
|
variant="pills"
|
||||||
radius="md"
|
radius="md"
|
||||||
className="h-screen"
|
className="h-screen"
|
||||||
@@ -106,6 +116,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
<Tabs.Tab value="ui-components" leftSection={<Layout size={18} />}>
|
<Tabs.Tab value="ui-components" leftSection={<Layout size={18} />}>
|
||||||
UI Components
|
UI Components
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="layout-engine" leftSection={<Layout size={18} />}>
|
||||||
|
Layout Engine
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="forms" leftSection={<FileText size={18} />}>
|
<Tabs.Tab value="forms" leftSection={<FileText size={18} />}>
|
||||||
Form Engine
|
Form Engine
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -360,6 +373,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
# Core App Shell — Layout Engine Architecture & Usage Guide
|
||||||
|
|
||||||
|
> **Package**: `@repo/ui` · **Module Path**: `@repo/ui/components`
|
||||||
|
> **Dependencies**: React 18+, Mantine v8 (`AppShell`), `@mantine/hooks`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Composition Model](#composition-model)
|
||||||
|
- [File Structure](#file-structure)
|
||||||
|
- [API Reference](#api-reference)
|
||||||
|
- [CoreAppShellConfig](#coreappshellconfig)
|
||||||
|
- [Layout Variants](#layout-variants)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Dimensions](#dimensions)
|
||||||
|
- [Slots](#slots)
|
||||||
|
- [Context API](#context-api)
|
||||||
|
- [Usage Examples](#usage-examples)
|
||||||
|
- [Minimal Setup](#minimal-setup)
|
||||||
|
- [Header-First with Utility Bar](#header-first-with-utility-bar)
|
||||||
|
- [Double Sidebar (Rail + Panel)](#double-sidebar-rail--panel)
|
||||||
|
- [Interactive Config Builder](#interactive-config-builder)
|
||||||
|
- [CorePageContainer](#corepagecontainer)
|
||||||
|
- [Design Decisions & Caveats](#design-decisions--caveats)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The **Core App Shell** is a configuration-driven layout engine that wraps Mantine's `AppShell` component. It provides a single `<CoreAppShell>` component that renders enterprise-grade application frames — complete with headers, sidebars, aside panels, utility bars, and footers — controlled entirely through a declarative `config` object and slot-based content injection.
|
||||||
|
|
||||||
|
Key capabilities:
|
||||||
|
|
||||||
|
- **Three layout variants** — `header-first`, `sidebar-first`, and `top-nav` — covering the most common enterprise SaaS patterns
|
||||||
|
- **Double sidebar** — Google-style rail + contextual panel navigation
|
||||||
|
- **Smart defaults** — Slots auto-detect presence; no explicit feature flags needed for basic layouts
|
||||||
|
- **Responsive out-of-the-box** — Mobile drawer, desktop collapse, and mini-sidebar are all built-in
|
||||||
|
- **State persistence** — Optional `localStorage`-backed sidebar state via `@mantine/hooks`
|
||||||
|
- **Context API** — All layout toggle methods (`toggleMobile`, `toggleDesktop`, `setSidebarVariant`, etc.) are available to any descendant component via `useCoreAppShell()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Composition Model
|
||||||
|
|
||||||
|
The layout engine uses a **Provider → Inner** composition pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
CoreAppShell (Public API)
|
||||||
|
└── CoreAppShellProvider (Context — state management)
|
||||||
|
└── CoreAppShellInner (Layout rendering — consumes context)
|
||||||
|
└── Mantine <AppShell> (CSS Grid engine)
|
||||||
|
├── AppShell.Header ← slots.utilityBar + slots.header
|
||||||
|
├── AppShell.Navbar ← slots.sidebar | slots.sidebarRail + slots.sidebarPanel
|
||||||
|
├── AppShell.Main ← children
|
||||||
|
├── AppShell.Aside ← slots.aside
|
||||||
|
└── AppShell.Footer ← slots.footer
|
||||||
|
```
|
||||||
|
|
||||||
|
The outer `CoreAppShell` is a thin wrapper that instantiates the provider and passes config down. The inner component subscribes to context and derives all layout calculations (navbar width, header height, collapse states) from the live config + user interactions.
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/ui/src/components/core-app-shell/
|
||||||
|
├── types.ts # All TypeScript interfaces and union types
|
||||||
|
├── core-app-shell-context.tsx # Context provider + useCoreAppShell hook
|
||||||
|
├── core-app-shell.tsx # Main component (Public API + Inner renderer)
|
||||||
|
├── core-page-container.tsx # Companion page-level content wrapper
|
||||||
|
└── index.ts # Barrel exports
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source**: [`core-app-shell/`](../src/components/core-app-shell/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### CoreAppShellConfig
|
||||||
|
|
||||||
|
The top-level configuration object that controls the entire layout:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CoreAppShellConfig {
|
||||||
|
variant: LayoutVariant;
|
||||||
|
dimensions?: CoreAppShellDimensions;
|
||||||
|
features?: CoreAppShellFeatures;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `variant` | `LayoutVariant` | ✅ | Determines the structural layout mode |
|
||||||
|
| `dimensions` | `CoreAppShellDimensions` | — | Override default pixel dimensions |
|
||||||
|
| `features` | `CoreAppShellFeatures` | — | Toggle optional layout regions and behaviors |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Layout Variants
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type LayoutVariant = 'header-first' | 'sidebar-first' | 'top-nav';
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variant | Mantine `layout` | Visual Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `header-first` | `default` | Header spans the full viewport width. Sidebar and aside sit **below** the header, stretching to the bottom of the screen. Footer is inset between the sidebar and aside. This is the most common enterprise/dashboard pattern (e.g., Azure Portal, Jira). |
|
||||||
|
| `sidebar-first` | `alt` | Sidebar spans the full viewport height. Header sits **to the right** of the sidebar. Produces a "desktop application" feel (e.g., VS Code, Slack). Footer spans full width beneath the sidebar. |
|
||||||
|
| `top-nav` | `default` | Header-only layout with **no visible desktop sidebar**. The sidebar is hidden on desktop but remains accessible as a mobile drawer on small screens. Ideal for documentation sites or marketing pages. |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> When `variant` is set to `top-nav`, the desktop navbar is visually hidden via `collapsed.desktop: true` and width `0`. However, the `<AppShell.Navbar>` DOM element remains mounted with responsive width props so the mobile drawer continues to function. This is an intentional design choice to avoid conditional DOM removal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CoreAppShellFeatures {
|
||||||
|
desktopCollapseVariant?: DesktopCollapseVariant;
|
||||||
|
withUtilityBar?: boolean;
|
||||||
|
withAside?: boolean;
|
||||||
|
withFooter?: boolean;
|
||||||
|
withDoubleSidebar?: boolean;
|
||||||
|
persistState?: boolean;
|
||||||
|
zIndex?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `desktopCollapseVariant` | `'hide' \| 'mini'` | `'hide'` | **`hide`**: Sidebar slides out completely (collapsed width = 0). **`mini`**: Sidebar shrinks to `sidebarMiniWidth` showing only icons. |
|
||||||
|
| `withUtilityBar` | `boolean` | Auto-detected | Show the utility bar above the header. If omitted, the bar renders when a `utilityBar` slot is provided. Set explicitly to `false` to suppress. |
|
||||||
|
| `withAside` | `boolean` | Auto-detected | Show the right-hand aside panel. Same auto-detection logic as `withUtilityBar`. |
|
||||||
|
| `withFooter` | `boolean` | Auto-detected | Show the bottom footer. Same auto-detection logic. |
|
||||||
|
| `withDoubleSidebar` | `boolean` | `false` | Enable the **Rail + Panel** double sidebar mode. When `true`, the navbar renders `sidebarRail` and `sidebarPanel` slots instead of the single `sidebar` slot. |
|
||||||
|
| `persistState` | `boolean` | `true` (implied) | Persist sidebar variant (`expanded`/`mini`/`hidden`) to `localStorage` via `useLocalStorage`. Set to `false` for demos or ephemeral layouts. |
|
||||||
|
| `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. |
|
||||||
|
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Dimensions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CoreAppShellDimensions {
|
||||||
|
utilityBarHeight?: number | string;
|
||||||
|
headerHeight?: number | string;
|
||||||
|
sidebarWidth?: number | string;
|
||||||
|
sidebarMiniWidth?: number | string;
|
||||||
|
sidebarRailWidth?: number | string;
|
||||||
|
asideWidth?: number | string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `utilityBarHeight` | `number \| string` | `32` | Height of the utility bar strip above the header |
|
||||||
|
| `headerHeight` | `number \| string` | `60` | Height of the main header |
|
||||||
|
| `sidebarWidth` | `number \| string` | `260` | Width of the expanded sidebar |
|
||||||
|
| `sidebarMiniWidth` | `number \| string` | `80` | Width of the sidebar in `mini` collapse mode |
|
||||||
|
| `sidebarRailWidth` | `number \| string` | `54` | Width of the icon rail in double-sidebar mode |
|
||||||
|
| `asideWidth` | `number \| string` | `260` | Width of the right-hand aside panel |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> All dimension values accept both pixel numbers (e.g., `260`) and CSS strings (e.g., `'20rem'`). When both `headerHeight` and `utilityBarHeight` are numbers, they are summed directly. When either is a string, the engine wraps them in a `calc()` expression automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Slots
|
||||||
|
|
||||||
|
Content is injected via the `slots` prop — a flat object of named `ReactNode` values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CoreAppShellSlots {
|
||||||
|
utilityBar?: ReactNode;
|
||||||
|
header?: ReactNode;
|
||||||
|
sidebar?: ReactNode;
|
||||||
|
sidebarMobile?: ReactNode;
|
||||||
|
sidebarRail?: ReactNode;
|
||||||
|
sidebarPanel?: ReactNode;
|
||||||
|
aside?: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Slot | Location | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `utilityBar` | Above the header, hidden on mobile (`display: none` below `sm`) | Typically used for environment banners, announcements, or top-level links. |
|
||||||
|
| `header` | Main application header | Must contain its own `<Burger>` for mobile toggle (use `useCoreAppShell()` context). |
|
||||||
|
| `sidebar` | Desktop navbar body (single-sidebar mode) | Ignored when `withDoubleSidebar` is `true` — use `sidebarRail` + `sidebarPanel` instead. |
|
||||||
|
| `sidebarMobile` | Mobile drawer content | Falls back to `sidebar` if not provided. Use this to render a simplified mobile-specific navigation. |
|
||||||
|
| `sidebarRail` | Narrow icon rail (double-sidebar mode) | Only rendered when `withDoubleSidebar` is `true`. Separated from `sidebarPanel` by a 1px border. |
|
||||||
|
| `sidebarPanel` | Contextual panel beside the rail (double-sidebar mode) | Collapsible via `toggleNavbarPanel()`. Only rendered when `withDoubleSidebar` is `true` and `navbarPanelOpened` is `true`. |
|
||||||
|
| `aside` | Right-hand panel | Collapsible via `toggleAside()`. Only rendered when `withAside` is enabled. |
|
||||||
|
| `footer` | Bottom application footer | In `header-first` mode, the footer is inset between sidebar and aside. In `sidebar-first` mode, it spans the full width. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Context API
|
||||||
|
|
||||||
|
The `useCoreAppShell()` hook provides access to all layout state and toggle methods from any descendant component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCoreAppShell } from '@repo/ui/components';
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property / Method | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `mobileOpened` | `boolean` | Whether the mobile drawer is currently open |
|
||||||
|
| `desktopOpened` | `boolean` | Whether the desktop sidebar is expanded (only applies when `desktopCollapseVariant` is `'hide'`) |
|
||||||
|
| `sidebarVariant` | `SidebarVariant` | Current sidebar mode: `'expanded'` \| `'mini'` \| `'hidden'` |
|
||||||
|
| `asideOpened` | `boolean` | Whether the aside panel is currently visible |
|
||||||
|
| `navbarPanelOpened` | `boolean` | Whether the secondary panel in double-sidebar mode is expanded |
|
||||||
|
| `config` | `CoreAppShellConfig` | Read-only access to the current layout configuration |
|
||||||
|
| `toggleMobile()` | `() => void` | Toggle the mobile drawer open/closed |
|
||||||
|
| `toggleDesktop()` | `() => void` | Toggle the desktop sidebar open/closed |
|
||||||
|
| `toggleAside()` | `() => void` | Toggle the aside panel visibility |
|
||||||
|
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
|
||||||
|
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Minimal Setup
|
||||||
|
|
||||||
|
The simplest possible layout — a header and sidebar with all defaults:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
|
||||||
|
import { Group, Text, Box, Stack, Button, Burger } from '@repo/ui/components';
|
||||||
|
|
||||||
|
function MyHeader() {
|
||||||
|
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
||||||
|
return (
|
||||||
|
<Group h="100%" px="md">
|
||||||
|
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||||
|
<Text fw={700}>My Application</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: CoreAppShellConfig = {
|
||||||
|
variant: 'header-first',
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<CoreAppShell
|
||||||
|
config={config}
|
||||||
|
slots={{
|
||||||
|
header: <MyHeader />,
|
||||||
|
sidebar: (
|
||||||
|
<Stack p="md" gap="xs">
|
||||||
|
<Button variant="subtle" fullWidth>Dashboard</Button>
|
||||||
|
<Button variant="subtle" fullWidth>Settings</Button>
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Main content area</Text>
|
||||||
|
</CoreAppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Header-First with Utility Bar
|
||||||
|
|
||||||
|
A full enterprise layout with utility bar, aside, and footer:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
|
||||||
|
import { Group, Text, Box, Burger } from '@repo/ui/components';
|
||||||
|
|
||||||
|
function AppHeader() {
|
||||||
|
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
||||||
|
return (
|
||||||
|
<Group h="100%" px="md" justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||||
|
<Text fw={700} size="lg">Enterprise Dashboard</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: CoreAppShellConfig = {
|
||||||
|
variant: 'header-first',
|
||||||
|
features: {
|
||||||
|
desktopCollapseVariant: 'hide',
|
||||||
|
persistState: true,
|
||||||
|
},
|
||||||
|
dimensions: {
|
||||||
|
headerHeight: 60,
|
||||||
|
utilityBarHeight: 32,
|
||||||
|
sidebarWidth: 280,
|
||||||
|
asideWidth: 300,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<CoreAppShell
|
||||||
|
config={config}
|
||||||
|
slots={{
|
||||||
|
utilityBar: (
|
||||||
|
<Group h="100%" px="md" justify="flex-end">
|
||||||
|
<Text size="xs">v2.4.1 · Production</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
header: <AppHeader />,
|
||||||
|
sidebar: <MySidebar />,
|
||||||
|
aside: <MyAside />,
|
||||||
|
footer: (
|
||||||
|
<Group h="100%" px="md">
|
||||||
|
<Text size="sm">© 2026 Acme Corp</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MyPageContent />
|
||||||
|
</CoreAppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Double Sidebar (Rail + Panel)
|
||||||
|
|
||||||
|
Google-style navigation with an icon rail and a collapsible contextual panel:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CoreAppShell, CoreAppShellConfig, useCoreAppShell } from '@repo/ui/components';
|
||||||
|
import { Stack, Box, Text, Burger, Group } from '@repo/ui/components';
|
||||||
|
import { Home, Settings, BarChart2 } from 'lucide-react';
|
||||||
|
|
||||||
|
function AppHeader() {
|
||||||
|
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
||||||
|
return (
|
||||||
|
<Group h="100%" px="md">
|
||||||
|
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||||
|
<Text fw={700}>Admin Panel</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: CoreAppShellConfig = {
|
||||||
|
variant: 'sidebar-first',
|
||||||
|
features: {
|
||||||
|
withDoubleSidebar: true,
|
||||||
|
},
|
||||||
|
dimensions: {
|
||||||
|
sidebarRailWidth: 54,
|
||||||
|
sidebarWidth: 260,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<CoreAppShell
|
||||||
|
config={config}
|
||||||
|
slots={{
|
||||||
|
header: <AppHeader />,
|
||||||
|
sidebarRail: (
|
||||||
|
<Stack align="center" gap="lg" pt="md">
|
||||||
|
<Home size={24} />
|
||||||
|
<BarChart2 size={24} />
|
||||||
|
<Settings size={24} />
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
sidebarPanel: (
|
||||||
|
<Box p="md">
|
||||||
|
<Text fw={700} mb="sm">Navigation</Text>
|
||||||
|
{/* Contextual links based on active rail icon */}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
sidebarMobile: (
|
||||||
|
<Box p="md">
|
||||||
|
<Text fw={700}>Mobile Nav</Text>
|
||||||
|
{/* Simplified mobile navigation */}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Main content</Text>
|
||||||
|
</CoreAppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> When `withDoubleSidebar` is `true`, the `sidebar` slot is ignored on desktop. The navbar renders `sidebarRail` (fixed-width icon column) and `sidebarPanel` (collapsible contextual panel) side-by-side. On mobile, `sidebarMobile` takes priority, falling back to `sidebar` if not provided.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Interactive Config Builder
|
||||||
|
|
||||||
|
The showcase demo at `apps/web/src/apps/showcase/shell-demo/` demonstrates a live, interactive config builder where every feature toggle and variant switch updates the layout in real-time. The key pattern is managing `config` state externally and passing it as a prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { CoreAppShell, CoreAppShellConfig, LayoutVariant, DesktopCollapseVariant } from '@repo/ui/components';
|
||||||
|
|
||||||
|
function ShellDemo() {
|
||||||
|
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('header-first');
|
||||||
|
const [collapseVariant, setCollapseVariant] = useState<DesktopCollapseVariant>('hide');
|
||||||
|
const [withDoubleSidebar, setWithDoubleSidebar] = useState(false);
|
||||||
|
|
||||||
|
const config: CoreAppShellConfig = useMemo(() => ({
|
||||||
|
variant: layoutVariant,
|
||||||
|
features: {
|
||||||
|
desktopCollapseVariant: collapseVariant,
|
||||||
|
withDoubleSidebar,
|
||||||
|
persistState: false,
|
||||||
|
},
|
||||||
|
}), [layoutVariant, collapseVariant, withDoubleSidebar]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CoreAppShell config={config} slots={{ header: <MyHeader />, sidebar: <MySidebar /> }}>
|
||||||
|
{/* Config controls live here — they can use useCoreAppShell() for toggle methods */}
|
||||||
|
</CoreAppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CorePageContainer
|
||||||
|
|
||||||
|
A companion component for structuring page-level content within the `<AppShell.Main>` area. It provides a sticky page header and a contained, padded content region.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CorePageContainer } from '@repo/ui/components';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface CorePageContainerProps extends ContainerProps {
|
||||||
|
headerSlot?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `headerSlot` | `ReactNode` | — | Page-level header content (title, breadcrumbs, action buttons). Rendered above the main content with a bottom border. |
|
||||||
|
| `stickyHeader` | `boolean` | `false` | When `true`, the page header sticks to the top of the scroll area, offset by the AppShell header height via `var(--app-shell-header-offset)`. |
|
||||||
|
| `px` | `MantineSpacing` | `'md'` | Horizontal padding for both the header and content areas |
|
||||||
|
| `py` | `MantineSpacing` | `'md'` | Vertical padding for both the header and content areas |
|
||||||
|
| _...rest_ | `ContainerProps` | — | All other Mantine `Container` props are forwarded to the content region |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CoreAppShell config={config} slots={slots}>
|
||||||
|
<CorePageContainer
|
||||||
|
stickyHeader
|
||||||
|
headerSlot={
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text component="h1" size="xl" fw={700}>Users</Text>
|
||||||
|
<Button>Add User</Button>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UserTable />
|
||||||
|
</CorePageContainer>
|
||||||
|
</CoreAppShell>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions & Caveats
|
||||||
|
|
||||||
|
### Mobile Navbar Lifecycle
|
||||||
|
|
||||||
|
The `<AppShell.Navbar>` DOM element is **always mounted**, even when the layout variant is `top-nav`. The desktop content is hidden via `visibleFrom="sm"` and mobile content via `hiddenFrom="sm"`. This ensures Mantine's native drawer engine works correctly on mobile without conditional DOM removal breaking the transition animations.
|
||||||
|
|
||||||
|
### Footer Positioning in `header-first` Mode
|
||||||
|
|
||||||
|
In `header-first` mode, the footer is **inset** between the sidebar and aside using CSS custom properties:
|
||||||
|
|
||||||
|
```css
|
||||||
|
left: var(--app-shell-navbar-offset, 0px);
|
||||||
|
right: var(--app-shell-aside-offset, 0px);
|
||||||
|
```
|
||||||
|
|
||||||
|
In `sidebar-first` mode, the footer spans the full viewport width (`left: 0; right: 0`).
|
||||||
|
|
||||||
|
### Z-Index Strategy
|
||||||
|
|
||||||
|
| Element | `header-first` | `sidebar-first` |
|
||||||
|
|---|---|---|
|
||||||
|
| AppShell (base) | `200` (default) | `200` (default) |
|
||||||
|
| Navbar | `105` | `100` |
|
||||||
|
| Aside | `105` | `100` |
|
||||||
|
| Footer | `100` | `100` |
|
||||||
|
|
||||||
|
The elevated `105` z-index for navbar/aside in `header-first` mode ensures they render above the footer, which is positioned at `100`.
|
||||||
|
|
||||||
|
### Sidebar Width Calculation
|
||||||
|
|
||||||
|
The navbar width is dynamically computed based on multiple state variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
navbarWidth.sm =
|
||||||
|
isTopNav → 0
|
||||||
|
isDoubleSidebar + panelOpen → sidebarWidth
|
||||||
|
isDoubleSidebar + panelClosed → sidebarRailWidth
|
||||||
|
sidebarVariant === 'mini' → sidebarMiniWidth
|
||||||
|
default → sidebarWidth
|
||||||
|
```
|
||||||
|
|
||||||
|
On mobile and `xs` breakpoints, the width is always `100%` and `sidebarWidth` respectively, regardless of variant.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
|
import { CoreAppShellConfig, SidebarVariant } from './types';
|
||||||
|
|
||||||
|
interface CoreAppShellContextValue {
|
||||||
|
mobileOpened: boolean;
|
||||||
|
desktopOpened: boolean;
|
||||||
|
sidebarVariant: SidebarVariant;
|
||||||
|
asideOpened: boolean;
|
||||||
|
navbarPanelOpened: boolean;
|
||||||
|
config: CoreAppShellConfig;
|
||||||
|
toggleMobile: () => void;
|
||||||
|
toggleDesktop: () => void;
|
||||||
|
toggleAside: () => void;
|
||||||
|
toggleNavbarPanel: () => void;
|
||||||
|
setSidebarVariant: (variant: SidebarVariant) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoreAppShellContext = createContext<CoreAppShellContextValue | null>(null);
|
||||||
|
|
||||||
|
interface CoreAppShellProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
config: CoreAppShellConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoreAppShellProvider({ children, config }: CoreAppShellProviderProps) {
|
||||||
|
const [mobileOpened, setMobileOpened] = useState(false);
|
||||||
|
const [desktopOpened, setDesktopOpened] = useState(true);
|
||||||
|
const [asideOpened, setAsideOpened] = useState(false);
|
||||||
|
const [navbarPanelOpened, setNavbarPanelOpened] = useState(true);
|
||||||
|
|
||||||
|
const [sidebarVariant, setSidebarVariant] = useLocalStorage<SidebarVariant>({
|
||||||
|
key: 'core-app-shell-sidebar-variant',
|
||||||
|
defaultValue: 'expanded',
|
||||||
|
getInitialValueInEffect: !config.features?.persistState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMobile = () => setMobileOpened((o) => !o);
|
||||||
|
const toggleDesktop = () => setDesktopOpened((o) => !o);
|
||||||
|
const toggleAside = () => setAsideOpened((o) => !o);
|
||||||
|
const toggleNavbarPanel = () => setNavbarPanelOpened((o) => !o);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CoreAppShellContext.Provider
|
||||||
|
value={{
|
||||||
|
mobileOpened,
|
||||||
|
desktopOpened,
|
||||||
|
sidebarVariant,
|
||||||
|
asideOpened,
|
||||||
|
navbarPanelOpened,
|
||||||
|
config,
|
||||||
|
toggleMobile,
|
||||||
|
toggleDesktop,
|
||||||
|
toggleAside,
|
||||||
|
toggleNavbarPanel,
|
||||||
|
setSidebarVariant,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CoreAppShellContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCoreAppShell() {
|
||||||
|
const context = useContext(CoreAppShellContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useCoreAppShell must be used within CoreAppShellProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { AppShell, Flex, Box, Button } from '@mantine/core';
|
||||||
|
import { CoreAppShellProvider, useCoreAppShell } from './core-app-shell-context';
|
||||||
|
import { CoreAppShellConfig, CoreAppShellSlots, CoreAppShellDimensions } from './types';
|
||||||
|
|
||||||
|
|
||||||
|
const DEFAULT_DIMENSIONS: Required<CoreAppShellDimensions> = {
|
||||||
|
utilityBarHeight: 32,
|
||||||
|
headerHeight: 60,
|
||||||
|
sidebarWidth: 260,
|
||||||
|
sidebarMiniWidth: 80,
|
||||||
|
sidebarRailWidth: 54,
|
||||||
|
asideWidth: 260,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CoreAppShellInnerProps {
|
||||||
|
slots: CoreAppShellSlots;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
||||||
|
const { mobileOpened, desktopOpened, sidebarVariant, asideOpened, navbarPanelOpened, config, toggleMobile } = useCoreAppShell();
|
||||||
|
const { variant, dimensions, features } = config;
|
||||||
|
const dims = { ...DEFAULT_DIMENSIONS, ...dimensions };
|
||||||
|
|
||||||
|
const isDoubleSidebar = features?.withDoubleSidebar;
|
||||||
|
const isTopNav = variant === 'top-nav';
|
||||||
|
const isFooterOffset = variant === 'header-first';
|
||||||
|
const isSidebarFirst = variant === 'sidebar-first';
|
||||||
|
|
||||||
|
// Calculate Navbar Width based on states
|
||||||
|
const navbarWidth = useMemo(() => {
|
||||||
|
let desktopWidth = dims.sidebarWidth;
|
||||||
|
if (isDoubleSidebar) {
|
||||||
|
desktopWidth = navbarPanelOpened ? dims.sidebarWidth : dims.sidebarRailWidth;
|
||||||
|
} else if (sidebarVariant === 'mini' && dims.sidebarMiniWidth) {
|
||||||
|
desktopWidth = dims.sidebarMiniWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: '100%',
|
||||||
|
xs: dims.sidebarWidth,
|
||||||
|
sm: isTopNav ? 0 : desktopWidth,
|
||||||
|
};
|
||||||
|
}, [sidebarVariant, dims, isTopNav, isDoubleSidebar, navbarPanelOpened]);
|
||||||
|
|
||||||
|
// Determine AppShell Layout
|
||||||
|
const appShellLayout = variant === 'sidebar-first' ? 'alt' : 'default';
|
||||||
|
|
||||||
|
// Smart defaults for slots
|
||||||
|
const showUtilityBar = (features?.withUtilityBar ?? Boolean(slots.utilityBar)) && Boolean(slots.utilityBar);
|
||||||
|
const showAside = (features?.withAside ?? Boolean(slots.aside)) && Boolean(slots.aside);
|
||||||
|
const showFooter = (features?.withFooter ?? Boolean(slots.footer)) && Boolean(slots.footer);
|
||||||
|
|
||||||
|
|
||||||
|
// Header height needs to account for utility bar if present
|
||||||
|
const totalHeaderHeight = useMemo(() => {
|
||||||
|
if (!showUtilityBar) return dims.headerHeight;
|
||||||
|
// Basic summation assuming pixel values or numeric equivalents if both are numbers
|
||||||
|
if (typeof dims.headerHeight === 'number' && typeof dims.utilityBarHeight === 'number') {
|
||||||
|
return dims.headerHeight + dims.utilityBarHeight;
|
||||||
|
}
|
||||||
|
return `calc(${dims.headerHeight}${typeof dims.headerHeight === 'number' ? 'px' : ''} + ${dims.utilityBarHeight}${typeof dims.utilityBarHeight === 'number' ? 'px' : ''})`;
|
||||||
|
}, [dims.headerHeight, dims.utilityBarHeight, showUtilityBar]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
layout={appShellLayout}
|
||||||
|
disabled={features?.disabled}
|
||||||
|
zIndex={features?.zIndex ?? 200}
|
||||||
|
header={{ height: totalHeaderHeight }}
|
||||||
|
navbar={{
|
||||||
|
width: navbarWidth,
|
||||||
|
breakpoint: 'sm',
|
||||||
|
collapsed: {
|
||||||
|
mobile: !mobileOpened,
|
||||||
|
desktop: isTopNav ? true : (features?.desktopCollapseVariant === 'hide' ? !desktopOpened : false),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
aside={
|
||||||
|
showAside && dims.asideWidth
|
||||||
|
? {
|
||||||
|
width: dims.asideWidth,
|
||||||
|
breakpoint: 'sm',
|
||||||
|
collapsed: { mobile: !asideOpened, desktop: !asideOpened },
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
showFooter
|
||||||
|
? { height: 60 } // Example default footer height
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
|
<AppShell.Header>
|
||||||
|
<Flex direction="column" h="100%">
|
||||||
|
{showUtilityBar && (
|
||||||
|
<Box h={dims.utilityBarHeight} display={{ base: 'none', sm: 'block' }}>
|
||||||
|
{slots.utilityBar}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box flex={1}>
|
||||||
|
{slots.header}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
|
<AppShell.Navbar
|
||||||
|
zIndex={isFooterOffset ? 105 : 100}
|
||||||
|
maw={{ base: '100%', xs: dims.sidebarWidth, sm: 'none' }}
|
||||||
|
style={
|
||||||
|
isFooterOffset
|
||||||
|
? {
|
||||||
|
bottom: 0,
|
||||||
|
height: 'calc(100dvh - var(--app-shell-header-offset, 0px))',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box visibleFrom="sm" h="100%" display={isTopNav ? 'none' : undefined}>
|
||||||
|
{isDoubleSidebar ? (
|
||||||
|
<Flex h="100%" direction="row" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
w={dims.sidebarRailWidth}
|
||||||
|
h="100%"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRight: '1px solid var(--mantine-color-default-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slots.sidebarRail}
|
||||||
|
</Box>
|
||||||
|
{navbarPanelOpened && (
|
||||||
|
<Box flex={1} h="100%" style={{ overflow: 'hidden' }}>
|
||||||
|
{slots.sidebarPanel}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
slots.sidebar
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box hiddenFrom="sm" h="100%">
|
||||||
|
<Flex direction="column" h="100%">
|
||||||
|
{
|
||||||
|
isSidebarFirst && (
|
||||||
|
<Box p="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
|
||||||
|
<Button fullWidth variant="default" onClick={toggleMobile}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<Box flex={1} style={{ overflowY: 'auto' }}>
|
||||||
|
{slots.sidebarMobile || slots.sidebar}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
|
||||||
|
{showAside && (
|
||||||
|
<AppShell.Aside
|
||||||
|
zIndex={isFooterOffset ? 105 : 100}
|
||||||
|
style={
|
||||||
|
isFooterOffset
|
||||||
|
? {
|
||||||
|
bottom: 0,
|
||||||
|
height: 'calc(100dvh - var(--app-shell-header-offset, 0px))',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{slots.aside}
|
||||||
|
</AppShell.Aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AppShell.Main>
|
||||||
|
{children}
|
||||||
|
</AppShell.Main>
|
||||||
|
|
||||||
|
{showFooter && (
|
||||||
|
<AppShell.Footer
|
||||||
|
// zIndex={isFooterOffset ? 100 : 105}
|
||||||
|
zIndex={100}
|
||||||
|
style={
|
||||||
|
isFooterOffset
|
||||||
|
? {
|
||||||
|
left: 'var(--app-shell-navbar-offset, 0px)',
|
||||||
|
right: 'var(--app-shell-aside-offset, 0px)',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{slots.footer}
|
||||||
|
</AppShell.Footer>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoreAppShellProps {
|
||||||
|
config: CoreAppShellConfig;
|
||||||
|
slots: CoreAppShellSlots;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CoreAppShell({ config, slots, children }: CoreAppShellProps) {
|
||||||
|
return (
|
||||||
|
<CoreAppShellProvider config={config}>
|
||||||
|
<CoreAppShellInner slots={slots}>
|
||||||
|
{children}
|
||||||
|
</CoreAppShellInner>
|
||||||
|
</CoreAppShellProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Box, Container, Stack, ContainerProps } from '@mantine/core';
|
||||||
|
|
||||||
|
export interface CorePageContainerProps extends ContainerProps {
|
||||||
|
headerSlot?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CorePageContainer({
|
||||||
|
headerSlot,
|
||||||
|
stickyHeader = false,
|
||||||
|
children,
|
||||||
|
px = "md",
|
||||||
|
py = "md",
|
||||||
|
...others
|
||||||
|
}: CorePageContainerProps) {
|
||||||
|
return (
|
||||||
|
<Box m="calc(var(--mantine-spacing-md) * -1)">
|
||||||
|
<Stack gap={0}>
|
||||||
|
{headerSlot && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: stickyHeader ? 'sticky' : 'static',
|
||||||
|
top: stickyHeader ? 'var(--app-shell-header-offset, 0px)' : undefined,
|
||||||
|
zIndex: stickyHeader ? 10 : undefined,
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container fluid px={px} py={py}>
|
||||||
|
{headerSlot}
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Container fluid px={px} py={py} w="100%" {...others}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './core-app-shell-context';
|
||||||
|
export * from './core-app-shell';
|
||||||
|
export * from './core-page-container';
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type LayoutVariant = 'header-first' | 'sidebar-first' | 'top-nav';
|
||||||
|
export type SidebarVariant = 'expanded' | 'mini' | 'hidden';
|
||||||
|
export type DesktopCollapseVariant = 'hide' | 'mini';
|
||||||
|
|
||||||
|
export interface CoreAppShellDimensions {
|
||||||
|
utilityBarHeight?: number | string;
|
||||||
|
headerHeight?: number | string;
|
||||||
|
sidebarWidth?: number | string;
|
||||||
|
sidebarMiniWidth?: number | string;
|
||||||
|
sidebarRailWidth?: number | string;
|
||||||
|
asideWidth?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoreAppShellFeatures {
|
||||||
|
desktopCollapseVariant?: DesktopCollapseVariant;
|
||||||
|
withUtilityBar?: boolean;
|
||||||
|
withAside?: boolean;
|
||||||
|
withFooter?: boolean;
|
||||||
|
withDoubleSidebar?: boolean;
|
||||||
|
persistState?: boolean;
|
||||||
|
zIndex?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoreAppShellConfig {
|
||||||
|
variant: LayoutVariant;
|
||||||
|
dimensions?: CoreAppShellDimensions;
|
||||||
|
features?: CoreAppShellFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoreAppShellSlots {
|
||||||
|
utilityBar?: ReactNode;
|
||||||
|
header?: ReactNode;
|
||||||
|
sidebar?: ReactNode;
|
||||||
|
sidebarMobile?: ReactNode;
|
||||||
|
sidebarRail?: ReactNode;
|
||||||
|
sidebarPanel?: ReactNode;
|
||||||
|
aside?: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
@@ -9,3 +9,4 @@ export * from './system-pages/coming-soon';
|
|||||||
export * from './system-pages/forbidden';
|
export * from './system-pages/forbidden';
|
||||||
export * from './system-pages/maintenance';
|
export * from './system-pages/maintenance';
|
||||||
export * from './system-pages/not-found';
|
export * from './system-pages/not-found';
|
||||||
|
export * from './core-app-shell';
|
||||||
|
|||||||
Reference in New Issue
Block a user