Skip to main content

Layout primitives

App Shell code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/app-shell.blade.php

@props([
    'title' => null,
    'description' => null,
    'contained' => false,
    'transparentHeader' => false,
    'transparentFooter' => false,
    'sunken' => false,
    'hybrid' => false,
])

@php
    /*
    | Full authenticated app frame for SaaS/admin screens. Compose sidebar rows
    | with x-ui.sidebar.item and keep page content in the default slot.
    |
    | Usage:
    |   <x-ui.app-shell title="Dashboard">
    |       <x-slot:sidebar>...</x-slot:sidebar>
    |       <x-slot:sidebarFooter>Utility actions such as notifications.</x-slot:sidebarFooter>
    |       <x-slot:topbar>Search field or command-palette trigger.</x-slot:topbar>
    |       <x-slot:actions>Page-level header actions.</x-slot:actions>
    |       <x-slot:user>User avatar or account menu.</x-slot:user>
    |       <x-slot:footer>...</x-slot:footer>
    |
    | When a sidebar is provided, theme controls and the user slot render in the
    | sidebar footer instead of the top bar, and the top bar is hidden on desktop
    | (lg+) so the page header carries title and actions without duplication.
    |
    | hybrid keeps that sidebar and also shows a desktop utility top bar. Theme
    | controls, the user slot, and sidebarFooter then move into the top bar.
    | Use the topbar slot for search.
    |
    | transparentHeader — drop the page header background (keeps a subtle border)
    | transparentFooter — drop the footer background (keeps a subtle border)
    | sunken            — recess the sidebar and lift the page into one large card
    | hybrid            — keep the utility top bar visible on desktop with a sidebar
    |
    |   </x-ui.app-shell>
    */
    $hasSidebar = isset($sidebar);
    $chromeInSidebar = $hasSidebar && ! $hybrid;
    $chrome = $hybrid ? 'hybrid' : ($hasSidebar ? 'sidebar' : 'topbar');
    $rootClasses = implode(' ', [
        $contained ? 'h-full min-h-0' : 'min-h-screen',
        $sunken
            ? 'bg-gray-100 text-gray-900 dark:bg-gray-950 dark:text-gray-100'
            : 'bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100',
    ]);
@endphp

<div
    x-data="{ sidebarOpen: false }"
    data-app-shell-chrome="{{ $chrome }}"
    {{ $attributes->merge(['class' => $rootClasses]) }}
>
    <div
        x-show="sidebarOpen"
        x-cloak
        class="fixed inset-0 z-40 bg-gray-950/60 backdrop-blur-sm lg:hidden"
        x-transition.opacity
        x-on:click="sidebarOpen = false"
    ></div>

    <aside
        x-show="sidebarOpen"
        x-cloak
        class="fixed inset-y-0 start-0 z-50 flex w-72 flex-col border-e border-gray-200 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-900 lg:hidden"
        x-transition:enter="transform transition ease-out duration-200"
        x-transition:enter-start="-translate-x-full"
        x-transition:enter-end="translate-x-0"
        x-transition:leave="transform transition ease-in duration-150"
        x-transition:leave-start="translate-x-0"
        x-transition:leave-end="-translate-x-full"
        aria-label="{{ __('Mobile navigation') }}"
    >
        <div class="flex h-16 shrink-0 items-center justify-between border-b border-gray-200 px-5 dark:border-gray-800">
            @isset($brand)
                {{ $brand }}
            @else
                <x-ui.brand />
            @endisset

            <x-ui.icon-button icon="x" label="Close navigation" size="sm" x-on:click="sidebarOpen = false" />
        </div>

        <nav class="flex-1 min-h-0 space-y-1 overflow-y-auto p-3">
            {{ $sidebar ?? '' }}
        </nav>

        @if ($chromeInSidebar)
            <x-ui.app-shell.sidebar-footer>
                @isset($user)
                    <x-slot:user>{{ $user }}</x-slot:user>
                @endisset

                @isset($sidebarFooter)
                    <x-slot:sidebarFooter>{{ $sidebarFooter }}</x-slot:sidebarFooter>
                @endisset
            </x-ui.app-shell.sidebar-footer>
        @endif
    </aside>

    <div @class([
        $contained ? 'h-full min-h-0' : 'min-h-screen',
        'lg:grid lg:grid-cols-[16rem_1fr]' => ! $sunken,
        'lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-2 lg:p-2 xl:grid-cols-[16rem_minmax(0,1fr)]' => $sunken,
    ])>
        <aside @class([
            'hidden lg:flex lg:flex-col',
            'border-e border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900' => ! $sunken,
            'bg-transparent' => $sunken,
            'h-full min-h-0' => $contained,
            'min-h-full' => ! $contained && ! $sunken,
            'h-[calc(100vh-1rem)] min-h-0' => ! $contained && $sunken,
        ]) aria-label="{{ __('Sidebar navigation') }}">
            <div @class([
                'flex min-h-0 flex-col',
                'h-full' => $contained,
                'lg:sticky lg:top-0 lg:h-screen' => ! $contained && ! $sunken,
                'h-[calc(100vh-1rem)]' => ! $contained && $sunken,
            ])>
                <div @class([
                    'flex h-16 shrink-0 items-center px-5',
                    'border-b border-gray-200 dark:border-gray-800' => ! $sunken,
                ])>
                    @isset($brand)
                        {{ $brand }}
                    @else
                        <x-ui.brand />
                    @endisset
                </div>

                <nav class="flex-1 min-h-0 space-y-1 overflow-y-auto p-3">
                    {{ $sidebar ?? '' }}
                </nav>

                @if ($chromeInSidebar)
                    <x-ui.app-shell.sidebar-footer :integrated="$sunken">
                        @isset($user)
                            <x-slot:user>{{ $user }}</x-slot:user>
                        @endisset

                        @isset($sidebarFooter)
                            <x-slot:sidebarFooter>{{ $sidebarFooter }}</x-slot:sidebarFooter>
                        @endisset
                    </x-ui.app-shell.sidebar-footer>
                @endif
            </div>
        </aside>

        <div @class([
            'min-w-0 flex flex-col',
            'bg-white dark:bg-gray-900 lg:overflow-hidden lg:rounded-2xl lg:border lg:border-gray-200/90 lg:shadow-[0_1px_2px_rgba(15,23,42,0.04),0_12px_36px_rgba(15,23,42,0.06)] dark:lg:border-gray-800 dark:lg:shadow-[0_1px_2px_rgba(0,0,0,0.4),0_16px_44px_rgba(0,0,0,0.28)]' => $sunken,
            'h-full min-h-0 overflow-auto' => $contained,
            'min-h-screen' => ! $contained && ! $sunken,
            'min-h-screen lg:h-[calc(100vh-1rem)] lg:min-h-0 lg:overflow-y-auto' => ! $contained && $sunken,
        ])>
            <header @class([
                'sticky top-0 z-30 border-b border-gray-200 bg-white/90 backdrop-blur dark:border-gray-800 dark:bg-gray-900/90',
                'lg:hidden' => $chromeInSidebar,
            ]) @if ($hybrid) aria-label="{{ __('Utility toolbar') }}" @endif>
                <div class="flex h-16 items-center gap-4 px-4 sm:px-6 lg:px-8">
                    <div @class([
                        'flex items-center',
                        'lg:hidden' => $hybrid && isset($header),
                        'min-w-0' => ! $hybrid || ! isset($header),
                        'gap-3' => ! $sunken,
                        'gap-4 sm:gap-6' => $sunken,
                    ])>
                        <x-ui.icon-button
                            icon="list"
                            label="Open navigation"
                            :variant="$sunken ? 'outline' : 'ghost'"
                            :size="$sunken ? 'base' : 'sm'"
                            class="lg:hidden"
                            data-sidebar-trigger-style="{{ $sunken ? 'emphasized' : 'default' }}"
                            x-on:click="sidebarOpen = true"
                        />

                        @if (! $hybrid || ! isset($header))
                            <div class="min-w-0">
                                @if ($title)
                                    <h1 class="truncate text-base font-semibold text-gray-900 dark:text-white">{{ $title }}</h1>
                                @endif
                                @if ($description)
                                    <p class="truncate text-xs text-gray-500 dark:text-gray-400">{{ $description }}</p>
                                @endif
                            </div>
                        @endif
                    </div>

                    @isset($topbar)
                        <div class="min-w-0 flex-1">{{ $topbar }}</div>
                    @endisset

                    <div @class([
                        'flex shrink-0 items-center gap-2',
                        'ms-auto' => ! isset($topbar),
                    ])>
                        @isset($actions)
                            {{ $actions }}
                        @endisset

                        @if ($hybrid)
                            @isset($sidebarFooter)
                                {{ $sidebarFooter }}
                            @endisset

                            <x-theme-switcher />

                            @isset($user)
                                <div class="ms-1">{{ $user }}</div>
                            @endisset
                        @elseif (! $hasSidebar)
                            <x-theme-switcher />

                            @isset($user)
                                <div class="ms-1">{{ $user }}</div>
                            @endisset
                        @endif
                    </div>
                </div>
            </header>

            @isset($header)
                <div @class([
                    'border-b px-4 py-8 sm:px-6 lg:px-8',
                    'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-800' => ! $transparentHeader,
                    'border-gray-200/70 bg-transparent dark:border-gray-800/70' => $transparentHeader,
                ])>
                    {{ $header }}
                </div>
            @endisset

            <main id="main-content" @class([
                'px-4 py-8 sm:px-6 sm:py-10 lg:px-8',
                'flex flex-1 flex-col' => isset($footer),
            ])>
                {{ $slot }}
            </main>

            @isset($footer)
                <footer @class([
                    'border-t px-4 py-5 sm:px-6 lg:px-8',
                    'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-800' => ! $transparentFooter,
                    'border-gray-200/70 bg-transparent dark:border-gray-800/70' => $transparentFooter,
                ])>
                    {{ $footer }}
                </footer>
            @endisset
        </div>
    </div>
</div>

resources/views/components/ui/app-shell/sidebar-footer.blade.php

@props([
    'integrated' => false,
])

@php
    /*
    | Sidebar footer tray for app-shell sidebars. Groups the user, utility
    | actions, theme colour picker and dark mode toggle in a compact grid.
    */
    $rootClasses = $integrated
        ? 'shrink-0 border-t border-gray-200/60 px-3 pb-3 pt-3 dark:border-gray-800/60'
        : 'shrink-0 border-t border-gray-200 p-3 dark:border-gray-800';
@endphp

<div {{ $attributes->merge([
    'class' => $rootClasses,
    'data-sidebar-footer-style' => $integrated ? 'integrated' : 'framed',
]) }}>
    <div @class([
        'overflow-visible',
        'rounded-xl border border-gray-200/80 bg-gray-50/90 p-2 dark:border-gray-800/80 dark:bg-gray-900/50' => ! $integrated,
    ])>
        @if (isset($user) || isset($sidebarFooter))
            <div class="grid grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-2">
                <div class="flex min-w-0 items-center">
                    {{ $user ?? '' }}
                </div>

                <div class="flex items-center justify-center">
                    {{ $sidebarFooter ?? '' }}
                </div>
            </div>

            <div @class([
                'border-t',
                'my-2 border-gray-200/80 dark:border-gray-800/80' => ! $integrated,
                'my-1.5 border-gray-200/60 dark:border-gray-800/60' => $integrated,
            ])></div>
        @endif

        <div class="relative grid grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-2">
            <x-ui.theme-color-picker
                class="min-w-0"
                :full-width="true"
                placement="top"
                align="left"
            />

            <x-ui.theme-mode-toggle />
        </div>
    </div>
</div>