The app-shell navigation sidebar: a fixed rail of grouped links that collapses to icons on desktop and takes over as a sheet on mobile.
This is the chrome, not the list. sidebar_item/1 is here for when you are
writing the nav markup anyway, but when your nav already ships as data,
PetalComponents.Menu.vertical_menu/1 renders the same list from plain maps
and drops straight into the content area. The sidebar is the shell it sits in.
Five function components compose the shell:
sidebar_shell/1- the flex wrapper holding the sidebar and your page contentsidebar_nav/1- the<nav>landmark itself, with header/footer slotssidebar_group/1- a labelled (optionally collapsible) run of itemssidebar_item/1- one link, or a parent with nested sub-itemssidebar_trigger/1- the button that collapses the rail or opens the sheet
Usage
<.sidebar_shell for="app-sidebar">
<:sidebar>
<.sidebar_nav id="app-sidebar" label="Main" collapsible="icon">
<:header>
<.icon name="hero-cube" class="w-6 h-6 shrink-0" />
<span class="pc-sidebar__brand">Acme</span>
</:header>
<.sidebar_group label="Workspace">
<.sidebar_item label="Dashboard" path="/" icon="hero-home" active />
<.sidebar_item label="Inbox" path="/inbox" icon="hero-inbox" badge="12" />
</.sidebar_group>
<.sidebar_group label="Account" collapsible open>
<.sidebar_item label="Settings" icon="hero-cog-6-tooth">
<.sidebar_item label="Profile" path="/settings/profile" />
<.sidebar_item label="Billing" path="/settings/billing" />
</.sidebar_item>
</.sidebar_group>
<:footer>
<.sidebar_item label="Sign out" path="/sign-out" icon="hero-arrow-left-start-on-rectangle" />
</:footer>
</.sidebar_nav>
</:sidebar>
<header class="flex items-center gap-3 p-4">
<.sidebar_trigger for="app-sidebar" target="mobile" />
<h1>Dashboard</h1>
</header>
<main class="p-4">Your page</main>
</.sidebar_shell>Collapse and state
Collapse is client-side by design: sidebar_trigger/1 flips a data-collapsed
attribute on the sidebar with Phoenix.LiveView.JS, and CSS does the rest. No
round trip, no hook.
The server still owns the initial value through the collapsed attr, so the
first paint is already correct and a live_redirect cannot flash the wrong
state - the attribute is rendered, not read back from the client. To persist
the choice across navigation, keep it in your own assign (or a session cookie
you read in the plug pipeline) and pass it back in:
<.sidebar_nav id="app-sidebar" collapsed={@sidebar_collapsed}>...then have the trigger tell the server as well as the DOM:
<.sidebar_trigger for="app-sidebar" on_click={JS.push("toggle_sidebar")} />Responsive behaviour
Below the md breakpoint (768px) a collapsible="icon" or "offcanvas"
sidebar leaves the flow entirely and becomes an off-canvas sheet, opened by a
target="mobile" trigger. While the sheet is open the shell's content region
is marked inert, Escape closes it, clicking the scrim closes it, and focus
returns to the trigger. collapsible="none" stays put at every width.
Theming
The sidebar reads two custom properties, so apps can retune widths without
touching pc-* internals:
.pc-sidebar { --pc-sidebar-width: 18rem; --pc-sidebar-icon-width: 4.5rem; }The breakpoint is fixed at md in this version; CSS media queries cannot read
custom properties.
Summary
Functions
Closes the mobile sheet for the sidebar with the given id and restores focus to its trigger.
Opens the mobile sheet for the sidebar with the given id.
A labelled run of items. Pass collapsible to make the label a disclosure toggle.
One navigation item: icon, label, optional badge, optional nested sub-items.
The sidebar itself: a <nav> landmark wrapped in the collapse/sheet machinery.
The app shell: a flex row holding the sidebar beside your page content.
The button that collapses the rail (target="collapse") or opens the mobile
sheet (target="mobile").
Toggles the desktop collapsed state of the sidebar with the given id.
Functions
Closes the mobile sheet for the sidebar with the given id and restores focus to its trigger.
Opens the mobile sheet for the sidebar with the given id.
Marks the shell's content region inert, locks body scroll and moves focus into the nav.
A labelled run of items. Pass collapsible to make the label a disclosure toggle.
Attributes
id(:string) - defaults to a slug of the label. Defaults tonil.label(:string) - group heading. Omit for an unlabelled run of items. Defaults tonil.collapsible(:boolean) - turns the label into a disclosure button (WAI-ARIA disclosure pattern). Defaults tofalse.open(:boolean) - initial state when collapsible. Defaults totrue.on_toggle(Phoenix.LiveView.JS) - additional JS commands to run when the group is toggled (LiveView.JS only). Defaults to%Phoenix.LiveView.JS{ops: []}.class(:any) - CSS class for the group. Defaults tonil.- Global attributes are accepted.
Slots
inner_block(required) -sidebar_item/1children.
One navigation item: icon, label, optional badge, optional nested sub-items.
Attributes
id(:string) - defaults to a slug of the label. Defaults tonil.label(:string) (required) - the item text. Kept for screen readers when collapsed.path(:string) - where the item links to. Omit when it has sub-items. Defaults tonil.icon(:any) - a heroicon name ("hero-home"), a function component, or a raw SVG string - the house icon convention. Defaults tonil.active(:boolean) - marks the current page. Emitsaria-current="page". Your app decides, never the component. Defaults tofalse.badge(:string) - trailing badge text, e.g. an unread count. Defaults tonil.link_type(:string) - how the item navigates, matchingPetalComponents.Link.a/1. Defaults to"live_redirect". Must be one of"live_redirect","live_patch","a", or"button".open(:boolean) - initial state of the sub-menu, when it has one. Defaults tofalse.on_toggle(Phoenix.LiveView.JS) - additional JS commands to run when the sub-menu is toggled (LiveView.JS only). Defaults to%Phoenix.LiveView.JS{ops: []}.class(:any) - CSS class for the item. Defaults tonil.- Global attributes are accepted.
Slots
inner_block- nestedsidebar_item/1children. Turns the item into a disclosure.
The app shell: a flex row holding the sidebar beside your page content.
Optional - a sidebar_nav/1 works inside your own layout too - but the shell is
what gives the mobile sheet something to mark inert, so use it if you want
the accessible sheet behaviour for free.
Attributes
for(:string) (required) - id of thesidebar_nav/1this shell wraps. The content region is rendered as<for>-mainso the trigger can mark it inert while the mobile sheet is open.class(:any) - CSS class for the shell wrapper. Defaults tonil.- Global attributes are accepted.
Slots
sidebar(required) - thesidebar_nav/1itself.inner_block(required) - everything else in the shell - topbar, page content. Marked inert while the sheet is open.
The button that collapses the rail (target="collapse") or opens the mobile
sheet (target="mobile").
Attributes
for(:string) (required) - id of thesidebar_nav/1this button controls.id(:string) - atarget="mobile"trigger defaults to"<for>-trigger", which is where focus returns when the sheet closes. Collapse triggers get no id by default, so a shell can carry both without colliding. Defaults tonil.target(:string) - collapse toggles the desktop rail (hidden below md); mobile opens the off-canvas sheet (hidden from md up). Defaults to"collapse". Must be one of"collapse", or"mobile".label(:string) - accessible name for the button. Defaults to"Toggle sidebar".on_click(Phoenix.LiveView.JS) - additional JS commands to run on click - e.g.JS.push("toggle_sidebar")to mirror the state server-side. Defaults to%Phoenix.LiveView.JS{ops: []}.class(:any) - CSS class for the trigger. Defaults tonil.- Global attributes are accepted.
Slots
inner_block- custom button content. Defaults to a hamburger/panel icon.
Toggles the desktop collapsed state of the sidebar with the given id.
Pure client-side: flips data-collapsed and lets CSS do the work.