diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx index 7ca77b6..1f5a777 100644 --- a/apps/web/src/apps/index.tsx +++ b/apps/web/src/apps/index.tsx @@ -6,6 +6,7 @@ import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/component const AuthModule = lazy(() => import('./auth')); const AppModule = lazy(() => import('./modules')); const ShowcaseView = lazy(() => import('./showcase/showcase-view')); +const ShellDemo = lazy(() => import('./showcase/shell-demo')); export default function App() { const [colorScheme, setColorScheme] = useState('light'); @@ -29,6 +30,7 @@ export default function App() { /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/apps/showcase/shell-demo/index.tsx b/apps/web/src/apps/showcase/shell-demo/index.tsx new file mode 100644 index 0000000..a263ed0 --- /dev/null +++ b/apps/web/src/apps/showcase/shell-demo/index.tsx @@ -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 ( + + + + + + + + ); +} + +function MockHeader() { + const { mobileOpened, toggleMobile } = useCoreAppShell(); + return ( + + + + Mock Header (bg="green.1") + + + ); +} + +function MockMobileDrawer() { + return ( + + Mock Mobile Drawer (bg="yellow.1") + + + + + + ); +} + +function SettingRow({ title, description, control }: { title: string; description: string; control: ReactNode }) { + return ( + + + + {title} + {description} + + {control} + + + ); +} + +export default function ShellDemo() { + const [layoutVariant, setLayoutVariant] = useState('header-first'); + const [collapseVariant, setCollapseVariant] = useState('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 ( + + Mock Utility Bar (bg="blue.1") + + ), + header: , + sidebarMobile: , + sidebar: !withDoubleSidebar ? ( + + Mock Standard Navbar (bg="grape.1") + + + + + + + + ) : undefined, + sidebarRail: withDoubleSidebar ? ( + + + Mock Rail (bg="orange.1") + + + + + + ) : undefined, + sidebarPanel: withDoubleSidebar ? ( + + Mock Panel (bg="grape.1") + + + + + + + + ) : undefined, + aside: ( + + Mock Aside + This area could be used for notifications, help text, or contextual settings. + + ), + footer: ( + + Mock Footer (bg="gray.1") + + ), + + + }} + > + + + Layout Engine Interactive Demo + + + + + + } + stickyHeader + > + + + + + + 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' }, + ]} + /> + } + /> + + setCollapseVariant(value as DesktopCollapseVariant)} + data={[ + { label: 'Hide (Slide Out)', value: 'hide' }, + { label: 'Mini (Shrink)', value: 'mini' }, + ]} + /> + } + /> + + setWithDoubleSidebar(event.currentTarget.checked)} + /> + } + /> + + setWithUtilityBar(event.currentTarget.checked)} + /> + } + /> + + setWithAside(event.currentTarget.checked)} + /> + } + /> + + setWithFooter(event.currentTarget.checked)} + /> + } + /> + + + + Configuration Preview + + + +
{JSON.stringify(liveConfig, null, 2)}
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/apps/showcase/showcase-view.tsx b/apps/web/src/apps/showcase/showcase-view.tsx index b5579c7..cfc0db6 100644 --- a/apps/web/src/apps/showcase/showcase-view.tsx +++ b/apps/web/src/apps/showcase/showcase-view.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { ColorSchemeType, DensityType } from '@repo/ui/provider'; import { Button, @@ -41,6 +42,7 @@ interface ShowcaseViewProps { export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) { const [activeTab, setActiveTab] = useState('ui-components'); const { i18n } = useTranslation(); + const navigate = useNavigate(); // Mock data for the table const tableData = [ @@ -65,6 +67,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set return 'Global Event Bus Synchronization'; case 'hardware': return 'Hardware Integration & Printers'; + case 'layout-engine': + return 'Core Layout Engine & Variants'; default: return 'Architecture Showcase'; } @@ -76,7 +80,13 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set orientation="vertical" placement="left" value={activeTab} - onChange={setActiveTab} + onChange={(val) => { + if (val === 'layout-engine') { + navigate('/shell-demo'); + } else { + setActiveTab(val); + } + }} variant="pills" radius="md" className="h-screen" @@ -106,6 +116,9 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set }> UI Components + }> + Layout Engine + }> Form Engine @@ -360,6 +373,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set )} + + diff --git a/packages/ui/docs/CORE-APP-SHELL.md b/packages/ui/docs/CORE-APP-SHELL.md new file mode 100644 index 0000000..9db6246 --- /dev/null +++ b/packages/ui/docs/CORE-APP-SHELL.md @@ -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 `` 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 (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 `` 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 `` 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 `` 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 ( + + + My Application + + ); +} + +const config: CoreAppShellConfig = { + variant: 'header-first', +}; + +function App() { + return ( + , + sidebar: ( + + + + + ), + }} + > + Main content area + + ); +} +``` + +--- + +### 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 ( + + + + Enterprise Dashboard + + + ); +} + +const config: CoreAppShellConfig = { + variant: 'header-first', + features: { + desktopCollapseVariant: 'hide', + persistState: true, + }, + dimensions: { + headerHeight: 60, + utilityBarHeight: 32, + sidebarWidth: 280, + asideWidth: 300, + }, +}; + +function App() { + return ( + + v2.4.1 · Production + + ), + header: , + sidebar: , + aside: , + footer: ( + + © 2026 Acme Corp + + ), + }} + > + + + ); +} +``` + +--- + +### 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 ( + + + Admin Panel + + ); +} + +const config: CoreAppShellConfig = { + variant: 'sidebar-first', + features: { + withDoubleSidebar: true, + }, + dimensions: { + sidebarRailWidth: 54, + sidebarWidth: 260, + }, +}; + +function App() { + return ( + , + sidebarRail: ( + + + + + + ), + sidebarPanel: ( + + Navigation + {/* Contextual links based on active rail icon */} + + ), + sidebarMobile: ( + + Mobile Nav + {/* Simplified mobile navigation */} + + ), + }} + > + Main content + + ); +} +``` + +> [!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('header-first'); + const [collapseVariant, setCollapseVariant] = useState('hide'); + const [withDoubleSidebar, setWithDoubleSidebar] = useState(false); + + const config: CoreAppShellConfig = useMemo(() => ({ + variant: layoutVariant, + features: { + desktopCollapseVariant: collapseVariant, + withDoubleSidebar, + persistState: false, + }, + }), [layoutVariant, collapseVariant, withDoubleSidebar]); + + return ( + , sidebar: }}> + {/* Config controls live here — they can use useCoreAppShell() for toggle methods */} + + ); +} +``` + +--- + +## CorePageContainer + +A companion component for structuring page-level content within the `` 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 + + + Users + + + } + > + + + +``` + +--- + +## Design Decisions & Caveats + +### Mobile Navbar Lifecycle + +The `` 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. diff --git a/packages/ui/src/components/core-app-shell/core-app-shell-context.tsx b/packages/ui/src/components/core-app-shell/core-app-shell-context.tsx new file mode 100644 index 0000000..f7126b1 --- /dev/null +++ b/packages/ui/src/components/core-app-shell/core-app-shell-context.tsx @@ -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(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({ + 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 ( + + {children} + + ); +} + +export function useCoreAppShell() { + const context = useContext(CoreAppShellContext); + if (!context) { + throw new Error('useCoreAppShell must be used within CoreAppShellProvider'); + } + return context; +} diff --git a/packages/ui/src/components/core-app-shell/core-app-shell.tsx b/packages/ui/src/components/core-app-shell/core-app-shell.tsx new file mode 100644 index 0000000..b144f44 --- /dev/null +++ b/packages/ui/src/components/core-app-shell/core-app-shell.tsx @@ -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 = { + 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 ( + + + + {showUtilityBar && ( + + {slots.utilityBar} + + )} + + {slots.header} + + + + + + + {isDoubleSidebar ? ( + + + {slots.sidebarRail} + + {navbarPanelOpened && ( + + {slots.sidebarPanel} + + )} + + ) : ( + slots.sidebar + )} + + + + { + isSidebarFirst && ( + + + + ) + } + + {slots.sidebarMobile || slots.sidebar} + + + + + + {showAside && ( + + {slots.aside} + + )} + + + {children} + + + {showFooter && ( + + {slots.footer} + + )} + + ); +} + +export interface CoreAppShellProps { + config: CoreAppShellConfig; + slots: CoreAppShellSlots; + children: React.ReactNode; +} + +export function CoreAppShell({ config, slots, children }: CoreAppShellProps) { + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/components/core-app-shell/core-page-container.tsx b/packages/ui/src/components/core-app-shell/core-page-container.tsx new file mode 100644 index 0000000..8527df9 --- /dev/null +++ b/packages/ui/src/components/core-app-shell/core-page-container.tsx @@ -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 ( + + + {headerSlot && ( + + + {headerSlot} + + + )} + + + {children} + + + + ); +} diff --git a/packages/ui/src/components/core-app-shell/index.ts b/packages/ui/src/components/core-app-shell/index.ts new file mode 100644 index 0000000..309c439 --- /dev/null +++ b/packages/ui/src/components/core-app-shell/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export * from './core-app-shell-context'; +export * from './core-app-shell'; +export * from './core-page-container'; diff --git a/packages/ui/src/components/core-app-shell/types.ts b/packages/ui/src/components/core-app-shell/types.ts new file mode 100644 index 0000000..2dbc37e --- /dev/null +++ b/packages/ui/src/components/core-app-shell/types.ts @@ -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; +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 137e26a..a9338bc 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -9,3 +9,4 @@ export * from './system-pages/coming-soon'; export * from './system-pages/forbidden'; export * from './system-pages/maintenance'; export * from './system-pages/not-found'; +export * from './core-app-shell';