docs: add comprehensive Core App Shell architecture and usage guide

This commit is contained in:
Firman Ramdhani
2026-06-22 18:30:48 +07:00
parent 81de76a510
commit df579137cd
+530
View File
@@ -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.