Skip to content
Bladewell

<x-widget.sidebar-layout>

Sidebar layout Blade component for Laravel and Livewire

An app's frame: a sidebar with your brand, navigation and footer, a top bar that stays put, and the page scrolling under it. On large screens the sidebar collapses to a rail of icons, naming each on hover or focus, and can remember that in a cookie; on phones and tablets it slides in from the menu button, over a dimmed page that waits inert until it closes. Takes an accordion menu with icons. Works inside Livewire components.

php artisan bladewell:add sidebar-layout
Also adds
Accordion, Icon

Usage

Remember

remember="sidebar": the script writes the cookie, so Laravel can't have encrypted it. Leave it out of encryption in bootstrap/app.php, or it reads as empty and every page starts expanded:

PHP
->withMiddleware(function (Middleware $middleware): void {
    $middleware->encryptCookies(except: ['sidebar']);
})

// Then pass it back in your layout. It holds "collapsed" or "expanded"; with no cookie yet, the sidebar starts expanded:
// <x-widget.sidebar-layout remember="sidebar" :collapsed="request()->cookie('sidebar') === 'collapsed'">

Livewire

The layout usually sits in your app's layout file, around Livewire's page components. Inside a component, a render updates the slots and leaves the sidebar collapsed or open as the person left it. With wire:navigate each page brings the layout again, so pass collapsed from the remember cookie, as here, for the next page to keep the sidebar as it was, and mark the current link from the route.

Blade
<x-widget.sidebar-layout remember="sidebar" :collapsed="request()->cookie('sidebar') === 'collapsed'">
    <x-slot:sidebar>
        <x-widget.accordion.menu label="Main">
            <x-widget.accordion.menu-item href="{{ route('dashboard') }}" icon="layout-dashboard" wire:navigate :current="request()->routeIs('dashboard')">Dashboard</x-widget.accordion.menu-item>
            <x-widget.accordion.menu-item href="{{ route('orders.index') }}" icon="shopping-bag" wire:navigate :current="request()->routeIs('orders.*')">Orders</x-widget.accordion.menu-item>
        </x-widget.accordion.menu>
    </x-slot:sidebar>

    {{ $slot }}
</x-widget.sidebar-layout>

Examples

These examples also use Chat, which Sidebar layout doesn't need on its own. To use them as they are:

Terminal
php artisan bladewell:add sidebar-layout chat

Dashboard

An admin frame around a blank page. On a large screen, the button at the start of the top bar collapses the sidebar to its icons; hover or focus one for its name. A group's icon shows its links in a popover beside the rail, scrolling once they pass 18rem, like Reports; Esc or a click elsewhere closes it. On a phone or tablet, the menu button slides it in; Esc, the close button, the dimmed page or a link closes it. In your layout, leave out the box around it: it fills the window.

Show code
Blade
<div class="border-line h-[36rem] overflow-hidden rounded-2xl border">
    <x-widget.sidebar-layout>
        <x-slot:brand>
            <a href="#" class="focus-visible:ring-primary flex items-center gap-3 rounded-lg outline-none focus-visible:ring-2">
                <span aria-hidden="true" class="bg-primary-fill text-on-primary grid size-8 shrink-0 place-items-center rounded-lg text-sm font-bold">A</span>
                <span class="font-semibold">Acme</span>
            </a>
        </x-slot:brand>

        <x-slot:sidebar>
            <x-widget.accordion.menu label="Main">
                <x-widget.accordion.menu-item href="#dashboard" icon="layout-dashboard" current>Dashboard</x-widget.accordion.menu-item>
                <x-widget.accordion.menu-item href="#inbox" icon="inbox">Inbox</x-widget.accordion.menu-item>

                <x-widget.accordion variant="menu" title="Orders" icon="shopping-bag">
                    <x-widget.accordion.menu-item href="#orders">All orders</x-widget.accordion.menu-item>
                    <x-widget.accordion.menu-item href="#drafts">Drafts</x-widget.accordion.menu-item>
                    <x-widget.accordion.menu-item href="#returns">Returns</x-widget.accordion.menu-item>
                </x-widget.accordion>

                <x-widget.accordion variant="menu" title="Products" icon="package" :open="false">
                    <x-widget.accordion.menu-item href="#catalogue">Catalogue</x-widget.accordion.menu-item>
                    <x-widget.accordion.menu-item href="#inventory">Inventory</x-widget.accordion.menu-item>
                </x-widget.accordion>

                <x-widget.accordion.menu-item href="#customers" icon="users">Customers</x-widget.accordion.menu-item>
                <x-widget.accordion variant="menu" title="Reports" icon="chart-bar" :open="false">
                    @foreach (['Sales', 'Revenue', 'Refunds', 'Payouts', 'Taxes', 'Traffic', 'Conversion', 'Retention', 'Stock', 'Discounts'] as $report)
                        <x-widget.accordion.menu-item :href="'#'.strtolower($report)">{{ $report }}</x-widget.accordion.menu-item>
                    @endforeach
                </x-widget.accordion>

                <x-widget.accordion.menu-item href="#settings" icon="settings">Settings</x-widget.accordion.menu-item>
            </x-widget.accordion.menu>
        </x-slot:sidebar>

        <x-slot:footer>
            <div class="flex items-center gap-3">
                <span aria-hidden="true" class="bg-field grid size-8 shrink-0 place-items-center rounded-full text-xs font-semibold">JD</span>
                <span class="flex min-w-0 flex-col text-sm leading-tight">
                    <span class="truncate font-medium">Jane Doe</span>
                    <span class="text-foreground/70 truncate text-xs">jane@acme.test</span>
                </span>
            </div>
        </x-slot:footer>

        <x-slot:topbar>
            <p class="truncate font-semibold">Dashboard</p>
            <button type="button" aria-label="Notifications" class="focus-visible:ring-primary text-foreground/70 hover:bg-field hover:text-foreground ms-auto grid size-9 shrink-0 place-items-center rounded-lg outline-none focus-visible:ring-2">
                <x-widget.icon name="bell" class="size-5" />
            </button>
        </x-slot:topbar>

        <div class="border-line text-foreground/70 grid h-[48rem] place-items-center rounded-2xl border-2 border-dashed text-sm">Your page</div>
    </x-widget.sidebar-layout>
</div>

Collapsed

remember keeps the person's choice in a cookie, and collapsed passes it back, so the next page paints with the sidebar as they left it: here, a rail of icons until they open it. With groups="expand", a group's icon opens the sidebar again with that group open, instead of a popover. A link or group without an icon shows its first letter in the rail, like Help.

Invoices

Your page
Show code
Blade
<div class="border-line h-[30rem] overflow-hidden rounded-2xl border">
    <x-widget.sidebar-layout groups="expand" remember="sidebar" :collapsed="request()->cookie('sidebar', 'collapsed') === 'collapsed'">
        <x-slot:brand>
            <a href="#" class="focus-visible:ring-primary flex items-center gap-3 rounded-lg outline-none focus-visible:ring-2">
                <span aria-hidden="true" class="bg-primary-fill text-on-primary grid size-8 shrink-0 place-items-center rounded-lg text-sm font-bold">A</span>
                <span class="font-semibold">Acme</span>
            </a>
        </x-slot:brand>

        <x-slot:sidebar>
            <x-widget.accordion.menu label="Workspace">
                <x-widget.accordion.menu-item href="#home" icon="home">Home</x-widget.accordion.menu-item>

                <x-widget.accordion variant="menu" title="Billing" icon="credit-card">
                    <x-widget.accordion.menu-item href="#plans">Plans</x-widget.accordion.menu-item>
                    <x-widget.accordion.menu-item href="#invoices" current>Invoices</x-widget.accordion.menu-item>
                </x-widget.accordion>

                <x-widget.accordion.menu-item href="#team" icon="users">Team</x-widget.accordion.menu-item>
                <x-widget.accordion.menu-item href="#help">Help</x-widget.accordion.menu-item>
            </x-widget.accordion.menu>
        </x-slot:sidebar>

        <x-slot:topbar>
            <p class="truncate font-semibold">Invoices</p>
        </x-slot:topbar>

        <div class="border-line text-foreground/70 grid h-[40rem] place-items-center rounded-2xl border-2 border-dashed text-sm">Your page</div>
    </x-widget.sidebar-layout>
</div>

With chat

The panel slot: a chat (or notifications, or help) one click away. Its button sits at the end of the top bar; on a large screen the panel opens beside the page, which narrows and can still be used, and on a phone or tablet it slides over the page like the menu. Esc or its close button shuts it. It's as wide as the sidebar, and compact on the chat sizes its text to match. panel-label and panel-icon name and draw its button.

Dashboard

Your page
Show code
Blade
<div class="border-line h-[36rem] overflow-hidden rounded-2xl border">
    <x-widget.sidebar-layout panel-label="Chat" panel-icon="message-circle">
        <x-slot:brand>
            <a href="#" class="focus-visible:ring-primary flex items-center gap-3 rounded-lg outline-none focus-visible:ring-2">
                <span aria-hidden="true" class="bg-primary-fill text-on-primary grid size-8 shrink-0 place-items-center rounded-lg text-sm font-bold">A</span>
                <span class="font-semibold">Acme</span>
            </a>
        </x-slot:brand>

        <x-slot:sidebar>
            <x-widget.accordion.menu label="Main">
                <x-widget.accordion.menu-item href="#overview" icon="layout-dashboard" current>Dashboard</x-widget.accordion.menu-item>
                <x-widget.accordion.menu-item href="#orders" icon="shopping-bag">Orders</x-widget.accordion.menu-item>
                <x-widget.accordion.menu-item href="#customers" icon="users">Customers</x-widget.accordion.menu-item>
                <x-widget.accordion.menu-item href="#settings" icon="settings">Settings</x-widget.accordion.menu-item>
            </x-widget.accordion.menu>
        </x-slot:sidebar>

        <x-slot:topbar>
            <p class="truncate font-semibold">Dashboard</p>
            <button type="button" aria-label="Notifications" class="focus-visible:ring-primary text-foreground/70 hover:bg-field hover:text-foreground ms-auto grid size-9 shrink-0 place-items-center rounded-lg outline-none focus-visible:ring-2">
                <x-widget.icon name="bell" class="size-5" />
            </button>
        </x-slot:topbar>

        <x-slot:panel>
            <x-widget.chat variant="messages" label="Chat with Acme Support" compact class="min-h-0 flex-1">
                <x-widget.chat.divider>Today</x-widget.chat.divider>
                <x-widget.chat.message name="Sam from Support" :time="today()->setTime(9, 12)">Hi! Anything I can help you with today?</x-widget.chat.message>
                <x-widget.chat.message mine :time="today()->setTime(9, 14)" status="read">Where do I change our billing email?</x-widget.chat.message>
                <x-widget.chat.message name="Sam from Support" :time="today()->setTime(9, 15)">
                    Under Settings, then Billing. It updates on your next invoice. Here's a short guide:
                    <x-slot:attachments>
                        <x-widget.chat.attachment href="#billing-guide" name="billing-settings.pdf" :size="84_000" />
                    </x-slot:attachments>
                </x-widget.chat.message>
                <x-widget.chat.typing name="Sam" />

                <x-slot:composer>
                    <x-widget.chat.composer async :action="route('conversations.messages.store', 1)" maxlength="2000" />
                </x-slot:composer>
            </x-widget.chat>
        </x-slot:panel>

        <div class="border-line text-foreground/70 grid h-[40rem] place-items-center rounded-2xl border-2 border-dashed text-sm">Your page</div>
    </x-widget.sidebar-layout>
</div>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.sidebar-layout>

Prop Default Description
collapsed false Starts collapsed to a rail of icons on large screens. With remember, pass back what the person last chose: :collapsed="request()->cookie('sidebar') === 'collapsed'".
remember null Keep the person's collapsed or expanded choice in this cookie ("sidebar"), as collapsed or expanded, for the next page to pass to collapsed. Leave the cookie out of Laravel's encryption (see the remember note).
groups 'popover' What a group's icon does in the rail: popover shows its links beside the rail, in a list that scrolls past 18rem; expand opens the sidebar again with the group open.
id null The sidebar's id, which its buttons point at. Defaults to one made for it.
panel-label 'Chat' With a panel slot (a chat, notifications): what the panel and the top bar's button for it are called.
panel-icon 'message-circle' The icon on the top bar's button for the panel.

Accessibility

All 3 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What bladewell:add sidebar-layout writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/sidebar-layout/index.blade.php Show
index.blade.php
@props([
    // Starts collapsed to a rail of icons on large screens. With remember, pass back what the person last chose:
    // :collapsed="request()->cookie('sidebar') === 'collapsed'".
    'collapsed' => false,
    // Keep the person's collapsed or expanded choice in this cookie ("sidebar"), as collapsed or expanded, for the next
    // page to pass to collapsed. Leave the cookie out of Laravel's encryption (see the remember note).
    'remember' => null,
    // What a group's icon does in the rail: popover shows its links beside the rail, in a list that scrolls past 18rem;
    // expand opens the sidebar again with the group open.
    'groups' => 'popover',
    // The sidebar's id, which its buttons point at. Defaults to one made for it.
    'id' => null,
    // With a panel slot (a chat, notifications): what the panel and the top bar's button for it are called.
    'panelLabel' => 'Chat',
    // The icon on the top bar's button for the panel.
    'panelIcon' => 'message-circle',
])

@php
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($groups, ['popover', 'expand'], true)) {
        throw new \InvalidArgumentException("Unknown groups [{$groups}] for <x-widget.sidebar-layout>. Use one of: popover, expand.");
    }
    $id = app(\App\View\Widget\ElementIds::class)->claim($id ?? 'sidebar', explicit: $id !== null);
    $panelId = isset($panel) ? app(\App\View\Widget\ElementIds::class)->claim($id.'-panel') : null;
    $button = 'focus-visible:ring-primary text-foreground/70 hover:bg-field hover:text-foreground grid size-9 shrink-0 place-items-center rounded-lg outline-none transition-colors focus-visible:ring-2 motion-reduce:transition-none';
    // The sidebar's own easing: quick to start, long to settle, so it glides rather than slides.
    $ease = 'duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none';
@endphp

{{--
    An app's frame: a sidebar (brand, navigation, footer), a top bar that stays put, and the page scrolling under it.

    On large screens the sidebar sits beside the page and collapses to a rail of icons from the top bar's button, its
    width easing as the labels fade. On smaller ones it's off screen, and the menu button slides it in over a dimmed
    page, which is made inert meanwhile, so focus and screen readers stay in the sidebar until it closes.

    It fills the window (h-dvh), or its parent where that has a height of its own (max-h-full), like the examples here.
    The page scrolls inside it, not the window, so the top bar never moves.

    Put an <x-widget.accordion.menu> in the sidebar slot, with an icon on each link and group: collapsed, the menu
    shows those icons, names each one beside it on hover or focus, and a group's icon shows its links in a popover
    beside the rail (or, with groups="expand", opens the sidebar again).
    Collapsed, the brand and footer slots show their first 4.5rem, so lead with a 2rem logo mark or avatar.

    No inline style and no inline script: collapsed comes from the server, so the page paints in the right state.
--}}
{{-- wire:ignore.self here and below: the script sets whether it's collapsed or open, and the state of the buttons; a
     Livewire render would put back the server's. What's inside still updates. --}}
<div
    data-sidebar-layout
    wire:ignore.self
    @if ($collapsed) data-sidebar-collapsed @endif
    @if ($remember) data-sidebar-remember="{{ $remember }}" @endif
    data-sidebar-groups="{{ $groups }}"
    {{ $attributes->class(['group/layout text-foreground relative flex h-dvh max-h-full overflow-hidden bg-[color-mix(in_oklab,var(--color-field)_45%,var(--color-surface))]']) }}
>
    {{-- Below lg: the dimmed page behind the open sidebar or panel. A click on it closes them. --}}
    <div
        data-sidebar-close
        aria-hidden="true"
        @class([
            'invisible absolute inset-0 z-30 bg-black/40 opacity-0 backdrop-blur-[2px] transition-[opacity,visibility] lg:hidden',
            'group-data-sidebar-open/layout:visible group-data-sidebar-open/layout:opacity-100',
            'group-data-sidebar-panel-open/layout:visible group-data-sidebar-panel-open/layout:opacity-100',
            $ease,
        ])
    ></div>

    <div
        id="{{ $id }}"
        data-sidebar
        @class([
            // Below lg: a panel off the start edge, slid in over the page while open. invisible keeps the closed one
            // out of the Tab order and away from screen readers. Opening turns it visible at once (only translate eases),
            // so the script can move focus into it; closing keeps it visible until the slide ends.
            'bg-surface invisible absolute inset-y-0 start-0 z-40 flex w-72 max-w-[calc(100%-3rem)] -translate-x-full flex-col shadow-2xl transition-[translate,visibility] rtl:translate-x-full',
            'group-data-sidebar-open/layout:visible group-data-sidebar-open/layout:translate-x-0 group-data-sidebar-open/layout:transition-[translate]',
            // lg and up: a column beside the page, on the frame's tint, narrowing to a rail.
            'lg:visible lg:relative lg:z-auto lg:w-64 lg:max-w-none lg:shrink-0 lg:translate-x-0 lg:bg-transparent lg:shadow-none lg:transition-[width] lg:rtl:translate-x-0',
            'lg:group-data-sidebar-collapsed/layout:w-18',
            $ease,
        ])
    >
        <div class="flex h-16 shrink-0 items-center gap-3 px-5 lg:mt-2">
            <div class="-m-1 flex min-w-0 flex-1 items-center gap-3 overflow-hidden p-1 whitespace-nowrap">{{ $brand ?? '' }}</div>
            <button type="button" data-sidebar-close aria-label="Close menu" class="{{ $button }} -me-2 lg:hidden">
                <x-widget.icon name="x" class="size-5" />
            </button>
        </div>

        {{-- The menu fills what's left and scrolls on its own. Padded so that, collapsed, each icon sits in the middle
             of the rail; overflow-x hidden, so nothing scrolls sideways while the width eases. --}}
        <div class="flex min-h-0 flex-1 flex-col [&_[data-accordion-menu]]:min-h-0 [&_[data-accordion-menu]]:flex-1 [&_[data-accordion-menu]]:overflow-x-hidden [&_[data-accordion-menu]]:px-3.5 [&_[data-accordion-menu]]:py-2">
            {{ $sidebar ?? '' }}
        </div>

        @isset($footer)
            <div class="border-line shrink-0 border-t px-5 py-3.5"><div class="-m-1 overflow-hidden p-1 whitespace-nowrap">{{ $footer }}</div></div>
        @endisset
    </div>

    <div data-sidebar-content wire:ignore.self class="flex min-w-0 flex-1 flex-col lg:py-2 lg:pe-2">
        {{-- The page scrolls here, under the top bar. On lg it's a raised sheet on the frame's tint. --}}
        <div data-sidebar-scroll class="bg-surface lg:border-line relative min-h-0 flex-1 overflow-y-auto [scrollbar-width:thin] lg:rounded-2xl lg:border lg:shadow-sm">
            {{-- Frosted, and lined only once the page has scrolled under it (data-scrolled, set by the script). Its own top
                 corners are rounded to the sheet's: Chrome doesn't clip a backdrop filter to a rounded scroller. --}}
            <header data-sidebar-topbar wire:ignore.self class="lg:rounded-t-[calc(1rem-1px)] bg-surface/80 data-scrolled:border-line data-scrolled:shadow-sm sticky top-0 z-20 flex h-16 items-center gap-2 border-b border-transparent px-4 backdrop-blur-xl transition-[border-color,box-shadow] duration-200 sm:px-6 motion-reduce:transition-none">
                <button type="button" data-sidebar-menu wire:ignore.self aria-label="Open menu" aria-controls="{{ $id }}" aria-expanded="false" class="{{ $button }} -ms-2 lg:hidden">
                    <x-widget.icon name="menu" class="size-5" />
                </button>
                <button type="button" data-sidebar-collapse wire:ignore.self aria-label="Sidebar" aria-controls="{{ $id }}" aria-expanded="{{ $collapsed ? 'false' : 'true' }}" class="{{ $button }} -ms-2 hidden lg:grid">
                    <x-widget.icon name="panel-left" class="size-5 rtl:-scale-x-100" />
                </button>
                <span aria-hidden="true" class="bg-line me-1 h-5 w-px"></span>
                <div class="flex min-w-0 flex-1 items-center gap-3">{{ $topbar ?? '' }}</div>
                @if ($panelId)
                    <button type="button" data-sidebar-panel-toggle wire:ignore.self aria-label="{{ $panelLabel }}" aria-controls="{{ $panelId }}" aria-expanded="false" class="{{ $button }} aria-expanded:bg-field aria-expanded:text-foreground -me-2">
                        <x-widget.icon :name="$panelIcon" class="size-5" />
                    </button>
                @endif
            </header>

            <main class="p-4 sm:p-6 lg:p-8">
                {{ $slot }}
            </main>
        </div>
    </div>

    @if ($panelId)
        {{-- The panel slot (a chat, say), opened from its button at the end of the top bar.
             As wide as the sidebar, at every size. Below lg: off the end edge, slid in over the dimmed page, which is
             inert meanwhile, as the sidebar is. lg and up: a sheet beside the page, which narrows to make room as its width eases open: the page can still
             be used. Closed, it's invisible, so out of the Tab order; opening turns it visible at once, so the script can
             move focus into it; closing keeps it visible until it has slid or narrowed away.
             wire:ignore.self: the script opens and closes it; what's inside still updates. --}}
        <aside
            id="{{ $panelId }}"
            data-sidebar-panel
            wire:ignore.self
            aria-label="{{ $panelLabel }}"
            @class([
                'bg-surface invisible absolute inset-y-0 end-0 z-40 flex w-72 max-w-[calc(100%-3rem)] translate-x-full flex-col shadow-2xl transition-[translate,visibility] rtl:-translate-x-full',
                'group-data-sidebar-panel-open/layout:visible group-data-sidebar-panel-open/layout:translate-x-0 group-data-sidebar-panel-open/layout:transition-[translate]',
                'lg:relative lg:z-auto lg:w-0 lg:max-w-none lg:shrink-0 lg:translate-x-0 lg:overflow-hidden lg:bg-transparent lg:shadow-none lg:transition-[width,visibility] lg:rtl:translate-x-0',
                'lg:group-data-sidebar-panel-open/layout:w-[16.5rem] lg:group-data-sidebar-panel-open/layout:transition-[width]',
                $ease,
            ])
        >
            {{-- Its own width on lg, so what's inside doesn't squeeze as the panel opens and closes around it. --}}
            <div class="bg-surface lg:border-line flex h-full min-h-0 w-full flex-col lg:my-2 lg:me-2 lg:h-[calc(100%-1rem)] lg:w-64 lg:overflow-hidden lg:rounded-2xl lg:border lg:shadow-sm">
                <div class="border-line flex h-16 shrink-0 items-center justify-between gap-3 border-b px-4 sm:px-5">
                    <h2 class="truncate font-semibold">{{ $panelLabel }}</h2>
                    <button type="button" data-sidebar-panel-close aria-label="Close {{ mb_strtolower($panelLabel) }}" class="{{ $button }} -me-2">
                        <x-widget.icon name="x" class="size-5" />
                    </button>
                </div>
                <div class="flex min-h-0 flex-1 flex-col">{{ $panel }}</div>
            </div>
        </aside>
    @endif

    {{-- Collapsed: the name of the link or group under the pointer or focus, beside the rail. One for the whole
         sidebar, in the top layer so the menu's scrolling can't clip it. Its tail (::before) points at the icon; overflow-visible,
         as a popover clips what sticks out of it. aria-hidden: each link is already named by
         its own text, which only fades out of sight. The script positions it (inline top and left); a Livewire render
         would wipe that. --}}
    <div
        data-sidebar-tip
        popover="manual"
        aria-hidden="true"
        wire:ignore.self
        class="bg-foreground text-surface before:bg-foreground pointer-events-none fixed inset-auto m-0 overflow-visible rounded-lg px-2.5 py-1.5 before:absolute before:-start-1 before:top-1/2 before:size-2 before:-translate-y-1/2 before:rotate-45 before:rounded-[1px] text-xs leading-snug font-medium whitespace-nowrap opacity-0 shadow-lg transition-opacity duration-150 open:opacity-100 starting:open:opacity-0 motion-reduce:transition-none"
    ></div>
</div>
resources/js/widget/sidebar-layout/index.js Show
index.js
// Drives <x-widget.sidebar-layout>. On large screens: collapses the sidebar to a rail of icons and back, remembers that
// in a cookie if asked, names each icon beside the rail on hover or focus, and shows a group's links in a popover beside
// it. On smaller ones: slides the sidebar in over the page from the menu button, with the page inert behind it, and out
// again on Esc, the close button, a click on the page, or a link chosen. And the panel slot, from its own button: beside
// the page on a large screen, over it on a smaller one. Delegated from `document`, so a layout added later or
// re-rendered by Livewire works without setting up.

const ROOT = '[data-sidebar-layout]';
// Tailwind's lg, where the sidebar stops being a panel over the page and sits beside it.
const wide = window.matchMedia('(min-width: 64rem)');
const GAP = 10;
const FOCUSABLE = 'a[href], button:not([disabled]), summary, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])';
const YEAR = 60 * 60 * 24 * 365;

const sidebarOf = (root) => root.querySelector(':scope > [data-sidebar]');
const contentOf = (root) => root.querySelector(':scope > [data-sidebar-content]');
const tipOf = (root) => root.querySelector(':scope > [data-sidebar-tip]');
const isOpen = (root) => root.hasAttribute('data-sidebar-open');
const isRail = (root) => wide.matches && root.hasAttribute('data-sidebar-collapsed');

function setCollapsed(root, collapsed) {
    root.toggleAttribute('data-sidebar-collapsed', collapsed);
    root.querySelectorAll('[data-sidebar-collapse]').forEach((button) => button.setAttribute('aria-expanded', String(!collapsed)));
    hideTip(root);
    hideFlyouts(root);
    const cookie = root.dataset.sidebarRemember;
    if (cookie) {
        document.cookie = `${encodeURIComponent(cookie)}=${collapsed ? 'collapsed' : 'expanded'}; path=/; max-age=${YEAR}; samesite=lax`;
    }
}

function open(root) {
    if (wide.matches || isOpen(root)) {
        return;
    }
    closePanel(root, { restoreFocus: false });
    root.setAttribute('data-sidebar-open', '');
    contentOf(root).inert = true;
    root.querySelectorAll('[data-sidebar-menu]').forEach((button) => button.setAttribute('aria-expanded', 'true'));
    // The current page's link if there is one, so a keyboard user starts where they are; otherwise the first thing.
    const sidebar = sidebarOf(root);
    const start = sidebar.querySelector('[aria-current="page"]') ?? sidebar.querySelector(`[data-accordion-menu] :is(${FOCUSABLE})`) ?? sidebar.querySelector(FOCUSABLE);
    start?.focus({ preventScroll: true });
}

function close(root, { restoreFocus = true } = {}) {
    if (!isOpen(root)) {
        return;
    }
    root.removeAttribute('data-sidebar-open');
    contentOf(root).inert = false;
    const button = root.querySelector('[data-sidebar-menu]');
    root.querySelectorAll('[data-sidebar-menu]').forEach((each) => each.setAttribute('aria-expanded', 'false'));
    if (restoreFocus) {
        button?.focus();
    }
}

// The panel slot (a chat, say), from its button at the end of the top bar. On a wide screen it sits beside the page,
// which can still be used; on a narrow one it slides over it, and the page is inert meanwhile, as with the sidebar.
// Only one of the two is over the page at a time.
const sidePanelOf = (root) => root.querySelector(':scope > [data-sidebar-panel]');
const isPanelOpen = (root) => root.hasAttribute('data-sidebar-panel-open');

function openPanel(root) {
    const panel = sidePanelOf(root);
    if (!panel || isPanelOpen(root)) {
        return;
    }
    close(root, { restoreFocus: false });
    hideTip(root);
    hideFlyouts(root);
    root.setAttribute('data-sidebar-panel-open', '');
    contentOf(root).inert = !wide.matches;
    root.querySelectorAll('[data-sidebar-panel-toggle]').forEach((button) => button.setAttribute('aria-expanded', 'true'));
    // Straight to where to type, if there's somewhere (a chat's box), so the person can start writing.
    (panel.querySelector('[autofocus], [data-chat-input], textarea, input:not([type="hidden"])') ?? panel.querySelector(FOCUSABLE))?.focus({ preventScroll: true });
}

function closePanel(root, { restoreFocus = true } = {}) {
    if (!isPanelOpen(root)) {
        return;
    }
    // Back to its button only from inside it: on a wide screen the person may have moved on to the page.
    const fromInside = sidePanelOf(root).contains(document.activeElement);
    root.removeAttribute('data-sidebar-panel-open');
    contentOf(root).inert = false;
    root.querySelectorAll('[data-sidebar-panel-toggle]').forEach((button) => button.setAttribute('aria-expanded', 'false'));
    if (restoreFocus && fromInside) {
        root.querySelector('[data-sidebar-panel-toggle]')?.focus();
    }
}

// The name beside the rail. Positioned here, as the menu scrolls under it; aria-hidden, as the link is already named.
function showTip(root, target) {
    const tip = tipOf(root);
    const name = (target.querySelector('[data-menu-label]') ?? target).textContent.trim().replace(/\s+/g, ' ') || target.getAttribute('aria-label');
    if (!tip || !name) {
        return;
    }
    tip.textContent = name;
    if (!tip.matches(':popover-open')) {
        tip.showPopover();
    }
    const box = target.getBoundingClientRect();
    const rtl = getComputedStyle(target).direction === 'rtl';
    tip.style.top = `${Math.round(box.top + box.height / 2 - tip.offsetHeight / 2)}px`;
    tip.style.left = `${Math.round(rtl ? box.left - GAP - tip.offsetWidth : box.right + GAP)}px`;
}

function hideTip(root) {
    const tip = tipOf(root);
    if (tip?.matches(':popover-open')) {
        tip.hidePopover();
    }
}

// What in the rail has a tip: the menu's links and group headings, but not the links in a group's popover, which show
// their names, nor a heading whose popover is showing.
const tipTarget = (element) => {
    const target = element instanceof Element ? element.closest('[data-sidebar] [data-accordion-menu] :is(a[href], summary)') : null;
    const root = target?.closest(ROOT);
    if (!root || !isRail(root) || target.closest('[data-accordion-content]') || target.parentElement.querySelector(':scope > [data-sidebar-flyout]:popover-open')) {
        return [];
    }

    return [root, target];
};

// groups="popover": a group's links in a popover beside the rail. It's the group's own list (the accordion's
// [data-accordion-content]), made a popover only while it shows, so the links stay where they are in the Tab order,
// straight after their heading, and nothing is copied. popover="auto" closes it on Esc or a click elsewhere.
const panelOf = (details) => details.querySelector(':scope > [data-accordion-content]');

function hideFlyouts(root) {
    root.querySelectorAll('[data-sidebar-flyout]:popover-open').forEach((panel) => panel.hidePopover());
}

function showFlyout(root, details) {
    const panel = panelOf(details);
    const summary = details.querySelector(':scope > summary');
    hideTip(root);
    hideFlyouts(root);
    // Its links only show while the group is open: open a closed one for now, and put it back as it was after.
    if (!details.open) {
        details.open = true;
        panel.setAttribute('data-sidebar-flyout-reclose', '');
    }
    panel.popover = 'auto';
    panel.setAttribute('data-sidebar-flyout', '');
    panel.showPopover();

    // Beside the rail, clear of it by the tail, its title level with the heading, and moved up as far as it takes to fit
    // the window. The tail stays on the icon however far it moved, short of the rounded corners.
    const rail = sidebarOf(root).getBoundingClientRect();
    const box = summary.getBoundingClientRect();
    const rtl = getComputedStyle(summary).direction === 'rtl';
    const top = Math.round(Math.max(GAP, Math.min(box.top - 6, window.innerHeight - panel.offsetHeight - GAP)));
    panel.style.top = `${top}px`;
    panel.style.left = `${Math.round(rtl ? rail.left - GAP - panel.offsetWidth : rail.right + GAP)}px`;
    panel.style.setProperty('--tail-y', `${Math.round(Math.min(Math.max(box.top + box.height / 2 - top, 16), panel.offsetHeight - 16))}px`);
    // Room before the list's scrollbar, only where it has one that takes up room: a long list, where scrollbars always
    // show. Overlay scrollbars and a list that fits need none.
    const list = panel.querySelector(':scope > ul');
    list?.toggleAttribute('data-scrollbar', list.offsetWidth > list.clientWidth);

    (panel.querySelector('[aria-current="page"]') ?? panel.querySelector('a[href]'))?.focus({ preventScroll: true });
}

// Back to the group's plain list once it closes, however that happened. Focus goes back to the heading when it was in the
// list (Esc), not when the person moved it somewhere else.
document.addEventListener('toggle', (event) => {
    const panel = event.target;
    if (event.newState !== 'closed' || !(panel instanceof Element) || !panel.hasAttribute('data-sidebar-flyout')) {
        return;
    }
    const details = panel.parentElement;
    const hadFocus = panel.contains(document.activeElement) || document.activeElement === document.body;
    panel.removeAttribute('popover');
    panel.removeAttribute('data-sidebar-flyout');
    panel.style.top = '';
    panel.style.left = '';
    panel.style.removeProperty('--tail-y');
    panel.querySelector(':scope > ul')?.removeAttribute('data-scrollbar');
    if (panel.hasAttribute('data-sidebar-flyout-reclose')) {
        panel.removeAttribute('data-sidebar-flyout-reclose');
        details.open = false;
    }
    if (hadFocus) {
        details.querySelector(':scope > summary')?.focus({ preventScroll: true });
    }
}, true);

// A press on the heading of a showing popover closes it (light dismiss) before the click arrives, which would only open
// it again: note it here, so that click leaves it closed.
document.addEventListener('pointerdown', (event) => {
    const summary = event.target instanceof Element ? event.target.closest('[data-sidebar] details[data-accordion] > summary') : null;
    if (summary) {
        summary.toggleAttribute('data-sidebar-flyout-was-open', !!summary.parentElement.querySelector(':scope > [data-sidebar-flyout]:popover-open'));
    }
}, true);

// A group's icon in the rail: its popover, or with groups="expand" the sidebar again, with that group open. Ahead of the
// accordion's own click (capture), so it doesn't also open or close the group.
document.addEventListener('click', (event) => {
    const summary = event.target instanceof Element ? event.target.closest('[data-sidebar] details[data-accordion] > summary') : null;
    const root = summary?.closest(ROOT);
    if (!root || !isRail(root)) {
        return;
    }
    const details = summary.parentElement;
    if (root.dataset.sidebarGroups === 'expand') {
        setCollapsed(root, false);
        if (details.open && !details.hasAttribute('data-closing')) {
            event.preventDefault();
            event.stopPropagation();
        }

        return;
    }
    event.preventDefault();
    event.stopPropagation();
    const wasOpen = summary.hasAttribute('data-sidebar-flyout-was-open');
    summary.removeAttribute('data-sidebar-flyout-was-open');
    const panel = panelOf(details);
    if (panel.matches(':popover-open')) {
        panel.hidePopover();
    } else if (!wasOpen) {
        showFlyout(root, details);
    }
}, true);

// Tab past the last link (or anywhere outside it and its heading) closes the popover.
document.addEventListener('focusin', (event) => {
    document.querySelectorAll('[data-sidebar-flyout]:popover-open').forEach((panel) => {
        if (!panel.contains(event.target) && event.target !== panel.parentElement.querySelector(':scope > summary')) {
            panel.hidePopover();
        }
    });
});

document.addEventListener('click', (event) => {
    if (!(event.target instanceof Element)) {
        return;
    }
    const root = event.target.closest(ROOT);
    if (!root) {
        return;
    }
    if (event.target.closest('[data-sidebar-menu]')) {
        open(root);
    } else if (event.target.closest('[data-sidebar-panel-toggle]')) {
        if (isPanelOpen(root)) {
            closePanel(root);
        } else {
            openPanel(root);
        }
    } else if (event.target.closest('[data-sidebar-panel-close]')) {
        closePanel(root);
    } else if (event.target.closest('[data-sidebar-close]')) {
        // The menu's close button, or the dimmed page behind whichever of the two is open.
        close(root);
        closePanel(root);
    } else if (event.target.closest('[data-sidebar-collapse]')) {
        setCollapsed(root, !root.hasAttribute('data-sidebar-collapsed'));
    } else if (event.target.closest('[data-sidebar] a[href]')) {
        // A link chosen: the panel or a group's popover gets out of the way of the page (or the #section) it leads to.
        close(root, { restoreFocus: false });
        hideFlyouts(root);
    }
});

document.addEventListener('keydown', (event) => {
    if (event.key !== 'Escape' || !(event.target instanceof Element)) {
        return;
    }
    document.querySelectorAll(ROOT).forEach((root) => {
        hideTip(root);
        if (isOpen(root)) {
            event.preventDefault();
            close(root);
        } else if (isPanelOpen(root)) {
            event.preventDefault();
            closePanel(root);
        }
    });
});

// Mouse and pen only: a tap follows the link, and a name left showing after it would only be in the way.
document.addEventListener('pointerover', (event) => {
    if (event.pointerType === 'touch') {
        return;
    }
    const [root, target] = tipTarget(event.target);
    if (root) {
        showTip(root, target);
    }
});
document.addEventListener('pointerout', (event) => {
    const [root, target] = tipTarget(event.target);
    if (root && !target.contains(event.relatedTarget)) {
        hideTip(root);
    }
});
document.addEventListener('focusin', (event) => {
    const [root, target] = tipTarget(event.target);
    if (root && target.matches(':focus-visible')) {
        // Once the browser is done: focus can arrive while a popover is still closing (Esc in a group's popover hands
        // it back to the group's icon), and no other may open until then. Still there by then, it gets its name.
        setTimeout(() => {
            if (document.activeElement === target && tipTarget(target).length > 0) {
                showTip(root, target);
            }
        });
    }
});
document.addEventListener('focusout', (event) => {
    const [root] = tipTarget(event.target);
    if (root) {
        hideTip(root);
    }
});

// The top bar gets its line once the page scrolls under it. A tip or a group's popover would be left behind by anything
// scrolling: the menu, or the window where the layout sits in a page rather than filling it. Not by the list inside a popover:
// scrolling it is how someone reaches its last links.
document.addEventListener('scroll', (event) => {
    if (event.target instanceof Element && event.target.matches('[data-sidebar-scroll]')) {
        event.target.querySelector(':scope > [data-sidebar-topbar]')?.toggleAttribute('data-scrolled', event.target.scrollTop > 0);
    }
    if (event.target instanceof Element && event.target.closest('[data-sidebar-flyout]')) {
        return;
    }
    const root = event.target instanceof Element ? event.target.closest(ROOT) : null;
    (root ? [root] : document.querySelectorAll(ROOT)).forEach((each) => {
        hideTip(each);
        hideFlyouts(each);
    });
}, true);

// Turned to a wide screen with the panel open: it's a column now, so the page mustn't stay inert. And the rail's
// names and popovers don't belong on a narrow one.
wide.addEventListener('change', () => document.querySelectorAll(ROOT).forEach((root) => {
    close(root, { restoreFocus: false });
    // An open panel stays open: beside the page now, which can be used, or over it, which can't.
    if (isPanelOpen(root)) {
        contentOf(root).inert = !wide.matches;
    }
    hideTip(root);
    hideFlyouts(root);
}));
app/View/Widget/ElementIds.php Show
ElementIds.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Container\Attributes\Scoped;
use LogicException;

/**
 * Keeps element ids unique within one response, so labels, aria-describedby and #fragments
 * always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
 */
#[Scoped]
final class ElementIds
{
    /** @var array<string, true> */
    private array $used = [];

    /**
     * Reserves an id for this response.
     *
     * A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
     * id is one the caller chose and may reference from JS or CSS, so silently renaming it would
     * break that reference; a duplicate throws instead, which surfaces in development and tests.
     */
    public function claim(string $id, bool $explicit = false): string
    {
        if (! isset($this->used[$id])) {
            return $this->reserve($id);
        }

        if ($explicit) {
            throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
        }

        $suffix = 2;
        while (isset($this->used["{$id}-{$suffix}"])) {
            $suffix++;
        }

        return $this->reserve("{$id}-{$suffix}");
    }

    private function reserve(string $id): string
    {
        $this->used[$id] = true;

        return $id;
    }
}