Skip to content

feat(core): add SidebarLayout body slot for custom page layouts - #437

Draft
interacsean wants to merge 4 commits into
mainfrom
feat/core/1643-sidebar-layout-body-slot
Draft

feat(core): add SidebarLayout body slot for custom page layouts#437
interacsean wants to merge 4 commits into
mainfrom
feat/core/1643-sidebar-layout-body-slot

Conversation

@interacsean

Copy link
Copy Markdown
Contributor

What

Adds an eject seam for everything to the right of the sidebar, so consumers can arrange their own columns — a table-of-contents rail, an edge-docked assistant panel — without overriding AppShell internals.

Addresses tailor-inc/platform-planning#1643, which merges two field reports with one root cause:

Both were working around the same wall: the only handle on the content region is the children render prop, which renders inside the padded, scrolling, header-topped inset. So consumers reached for :has() + !important overrides on [data-slot="sidebar-inset"] / [data-slot="sidebar-wrapper"], plus a MutationObserver on [data-state] and a hidden-trigger .click() to track sidebar collapse.

API

<SidebarLayout
  body={
    <>
      <TocRail />
      <SidebarLayout.ContentContainer header={<SidebarLayout.DefaultHeader />}>
        <SidebarLayout.Outlet />
      </SidebarLayout.ContentContainer>
      <AssistantPanel />
    </>
  }
/>
  • body replaces the region beside the sidebar. As a flex sibling it reflows on collapse for free. Supersedes header/children; warns in dev when combined.
  • SidebarLayout.ContentContainer — the stock content column (inset padding, pinned header slot, scroll region), extracted. It now owns the scroll ref, so useAppShellScrollContainer() keeps working inside a custom body. The default path renders the same component — one implementation, not two.
  • SidebarLayout.Outlet / SidebarLayout.Trigger — page outlet and collapse toggle, for composing custom bodies.
  • useAppShellSidebar(){ open, isMobile, setOpen, toggle }, inert outside a SidebarLayout. Replaces the MutationObserver + hidden-.click() workaround.

SidebarInset width change

w-[calc(100%-var(--sidebar-width))]min-w-0.

That calc hardcoded "exactly one sibling, exactly --sidebar-width wide" — the assumption body breaks. It dates to the initial commit's scaffold (carried through the file split untouched, no comment, no test) and was really a workaround for a missing min-w-0: SidebarInset is w-full flex-1 with no min-w-0, so its flex min-width: auto resolved against w-full and overflowed the row by the sidebar's width. min-w-0 is the flex idiom already used five times elsewhere in that file.

Verified on /dashboard/products at 1280px: min-width: 0px, inset exactly 1024 = 1280 − 256, zero overflow on document, wrapper, or row.

Verified

  • Four columns render with no overflow; assistant panel flush at the viewport edge; content column keeps its breadcrumb header and md:px-8 padding
  • Sidebar collapse reflows every column; rail label flips via useAppShellSidebar()
  • useAppShellScrollContainer() resolves inside a custom body
  • Dark mode, mobile, and the Next.js example (default path, no body) all unchanged
  • 1430 tests pass, no type errors, lint clean

Still to come

Pilot scope — docs, changeset, and tests for the new surface are not in this PR yet.

Open design question: body is configured at app level, but both reported use cases are page-level. The vite example drives it off useLocation(), which works but means shell config has to know page routes. Options written up in decisions/page-level-body-columns.md.

interacsean and others added 4 commits August 12, 2026 12:40
Adds an eject seam for everything to the right of the sidebar, so consumers
can arrange their own columns — a table-of-contents rail, an edge-docked
assistant panel — without overriding AppShell internals.

- `body` prop on SidebarLayout replaces the region beside the sidebar. As a
  flex sibling it reflows on sidebar collapse for free. Supersedes `header`
  and `children`; warns in dev when combined.
- `SidebarLayout.ContentContainer` extracts the stock content column (inset
  padding, pinned header slot, scroll region) and now owns the scroll ref, so
  `useAppShellScrollContainer()` keeps working inside a custom body. The
  default path renders the same component — one implementation, not two.
- `SidebarLayout.Outlet` / `SidebarLayout.Trigger` expose the page outlet and
  the collapse toggle for composing custom bodies.
- `useAppShellSidebar()` exposes collapse state and toggling, replacing
  MutationObserver-on-[data-state] plus hidden-trigger .click() workarounds.
- `SidebarInset` uses `min-w-0` instead of `w-[calc(100%-var(--sidebar-width))]`.
  That calc hardcoded "exactly one 16rem sibling" and dated to the initial
  scaffold; min-w-0 is the flex idiom already used elsewhere in the file and is
  what lets sibling columns share the row.

Pilot — docs, changeset and tests still to come.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `body` slot is configured at app level, but both field reports that
motivated it are page-scoped. Writes up three options — route-aware body
(works today), a portal-based page component, and page-metadata declaration
— plus one rejected approach.

Recommends the portal component: both reported panels are stateful and
page-scoped, and it is the only option that keeps the panel inside the page's
React tree, so page state flows in without being lifted into the shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… by type (#1643)

`SidebarLayoutProps` becomes a union of two variants: the default layout
(`header` + `children`) and the ejected one (`body`). Each marks the other's
props `never`, so passing `body` alongside `header`/`children` is a compile
error rather than a runtime warning after the confusion has already shipped.

This matters most for the case where a header is configured once at the
AppShell level and then a second one is placed inside `body` — previously the
outer one was silently dropped.

The runtime warn stays as a backstop: types are erased, so JS consumers and
`as any` escapes still get told rather than losing a header silently.

Verified the union accepts `header`/`children`/bare/`body`-only and rejects
`body`+`header` and `body`+`children`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The union already makes `body` + `header`/`children` a compile error, so the
runtime guard only ever fired for consumers who had bypassed the types. Not
worth the shipped bytes.

Removing it also removed the need for the widened internal props alias — the
component destructures straight off the union, since every member declares all
six props.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant