react-resource-view

Admin layout

AdminLayout: a ready-made decoratorComponent — sidebar, top bar, a bottom nav on mobile, and full-width views.

What it is#

A scope's decoratorComponent wraps every view of that scope — the navigation and the page heading around them. Writing one from scratch is a sidebar, a top bar and a mobile navigation that every project ends up rewriting; AdminLayout is that shell, ready to point a scope at.

See it running

The playground's whole back office is this template, configured in a line: users, companies, a blog, a catalogue and a task board, every screen a URL. The link opens the tasks — the resource drawn full width, next to screens that keep the column.

Installing it#

It ships with the package — nothing more to install than the installation already did, styles included:

import { AdminLayout, createAdminLayout } from "react-resource-view"

Pointing a scope at it#

Nothing beyond a menu is required — the sidebar, the top bar and the mobile navigation all read it — and menu: "auto" builds it from the scope's resources, one entry each. The scope's label, when it has one, opens the breadcrumb in the top bar: it is the name of the application.

scopes/admin.ts
import { AdminLayout } from "react-resource-view"

export const adminScope: ScopeInterface = {
  name: "admin",
  // The first link of the breadcrumb, in the top bar.
  label: "Back office",
  decoratorComponent: AdminLayout,
  // The one place a resource's scope is written: their links lead here.
  resources: [articles, authors],
  // One entry per resource, in order.
  menu: "auto",
  defaultViewResourceContextParams: { resourceId: articles["@id"] },
}

The menu is data, and the template draws it three ways: the sidebar on a desktop, the bottom bar on a phone, and the tabs under the page header. A resource is put in it as it is: the entry reads the name, the icon and the link off the resource — declared once, on the resource.

scopes/admin.ts
menu: [
  // A resource is an entry: its name, its icon and its link.
  overview,
  tasks,

  // A section: a collapsible group in the sidebar, a drawer on a phone.
  {
    name: "Catalogue",
    icon: ShoppingBag,
    items: [products, orders],
  },

  // A group whose pages are tabs under the page header rather than
  // entries of the sidebar.
  {
    name: "Settings",
    icon: Settings,
    subNavigation: true,
    items: [profile, billing],
  },
]
  • An entry with items is a section: a collapsible group in the sidebar, a drawer from the bottom bar.
  • With subNavigation: true, the group opens on its first page and the header shows the sibling pages as tabs instead of nesting them in the sidebar.
  • hidden: true keeps an entry out of all three — see scopes & menu.

An entry stays lit on everything inside what it points at: the list of a resource, one of its records, the form editing it.

A logo, a search, a user menu#

decoratorComponent only ever receives children, so AdminLayout itself takes no props there. createAdminLayout builds a configured variant instead, with a logo and top bar content baked in:

scopes/admin.ts
import { createAdminLayout } from "react-resource-view"

export const adminScope: ScopeInterface = {
  name: "admin",
  decoratorComponent: createAdminLayout({
    logo: <MyLogo />,
    // A search field, a ⌘K palette, a user menu — anything.
    topBarEnd: <UserMenu />,
  }),
  resources: [/* … */],
  menu: "auto",
}

The template has no search of its own: topBarEnd is the slot for one, a ⌘K palette included, and it stays on every screen of the scope. The playground puts its link to the builder there.

Help, support, documentation — the links an application keeps out of the way of its pages go in a block pinned at the bottom of the sidebar. footerMenu takes menu entries, drawn like the scope's menu, under an optional footerMenuTitle:

scopes/admin.ts
import { BookOpen, Github, Mail } from "lucide-react"
import { createAdminLayout } from "react-resource-view"

decoratorComponent: createAdminLayout({
  footerMenuTitle: "Need help?",
  footerMenu: [
    // An absolute URL opens in a new tab…
    { name: "Documentation", icon: BookOpen, href: "https://example.com/docs" },
    { name: "GitHub", icon: Github, href: "https://github.com/acme/app" },
    // …a mailto: hands over to the mail client…
    { name: "Contact", icon: Mail, href: "mailto:support@example.com" },
    // …and a page of the application stays in it. Built outside a scope's
    // menu, an entry names the scope its link leads into.
    createItemMenuWithResource({ resource: faq, scope: "admin" }),
  ],
})

For something else than links — a plan badge, a card, a button — sidebarFooter takes any node:

decoratorComponent: createAdminLayout({
  // Anything at all, under the footerMenu when there is one.
  sidebarFooter: <PlanBadge />,
})

Without either, the sidebar has no footer at all. On a phone, both go behind a last entry of the bottom bar, named after footerMenuTitle — « More » without one — which opens them in a drawer. The playground's back office has a « Need help? » block of this kind.

On a record, the header says which one is on screen and how to get back: a link to the list, the record's name as the title, the action and its id underneath, and the actions still worth offering — edit from the read page, delete from either. The same name ends the breadcrumb in the top bar.

The name is read from title, name or label; a resource — or one of its views — names another field with titleField. Each part of the header is a slot a view can fill:

views: {
  update: {
    // The field that names an account, instead of `title` or `name`.
    titleField: "email",
    components: {
      // Either of them, or both — the rest of the header stays.
      title: () => <h2>…</h2>,
      actions: () => <PublishButton />,
      // Or the whole header, tabs included.
      // navigation: () => <MyHeader />,
    },
  },
}

Full width#

Views are drawn in a column of constrained width — the right measure for a form or a record, too narrow for a board, a calendar, a timeline or a table with a dozen columns. A view declaring fullWidth takes the whole page instead; the side margins stay, and a phone is laid out as before.

createViewResource<Task>("tasks", {
  path: "/api/tasks",
  // Every action of the resource, across the whole page…
  fullWidth: true,
  views: {
    list: {
      layouts: [
        layout.board({ groupBy: "status", columns: STATUSES }),
        layout.calendar({ dateField: "dueDate" }),
      ],
    },
    // …but its forms, which keep the column.
    create: { fullWidth: false },
    update: { fullWidth: false },
  },
})

The option is read at three levels, and the most specific one wins: the list layout on screen, then the action's entry in views, then the root of the resource. Nothing declared means the column, so a resource that never mentions it is drawn exactly as before. For a single layout of a list:

layouts: [
  // Only the board is wide; the table stays in the column.
  layout.board({ groupBy: "status", columns: STATUSES, fullWidth: true }),
  layout.table(),
]

A decoratorComponent of your own honours the same option through isFullWidthView, which resolves it for the view on screen:

import { isFullWidthView, useCurrentViewResourceContext } from "react-resource-view"

function MyLayout({ children }: { children: ReactNode }) {
  const wide = isFullWidthView(useCurrentViewResourceContext())

  return <main className={wide ? "px-6" : "mx-auto max-w-5xl px-6"}>{children}</main>
}

With the rest of the declaration

The option is also listed with the rest of a resource's declaration, under declaring a resource.

Theme and dark mode#

Every surface of the template is drawn with the shadcn tokens — background, muted, border, primary… — so it takes the palette of an application that has a shadcn theme, and follows its .dark class. Without one, the stylesheet imported at installation supplies a light and a dark palette of its own.

Responsive by default#

Below the md breakpoint the sidebar is replaced with a bottom navigation bar; a menu entry with children opens a drawer instead of a nested menu, and the bottom of the sidebar goes behind a last « more » entry. Nothing to configure — AdminLayout switches on the viewport width itself.

The building blocks#

Each piece is exported on its own, for a shell that doesn't match this shape:

  • AdminSidebarNav — the desktop sidebar, built from the scope's menu, with its footer.
  • AdminTopBar — sticky top bar: the sidebar toggle, the breadcrumb, and a slot for the rest.
  • AdminHeader — page title, the record's actions, and the sub-navigation tabs a menu group opts into.
  • AdminMobileNav — bottom navigation bar, in place of the sidebar on narrow screens.

Reference#

NameTypeDefaultDescription
logoReactNode—Rendered in the sidebar header on desktop, and in the top bar on mobile.
topBarEndReactNode—Rendered at the end of the top bar — a search field, a user menu…
footerMenuMenuItemInterface[]—Links pinned at the bottom of the sidebar, drawn like the menu. An absolute href opens in a new tab. On mobile, in the drawer of the bottom bar's last entry.
footerMenuTitlestring—Heading above footerMenu — “Need help?”; names the last entry of the bottom bar on mobile.
sidebarFooterReactNode—Pinned at the bottom of the sidebar, under footerMenu. On mobile, in the same drawer.

And, read off the scope and its resources rather than passed as props:

NameTypeDefaultDescription
scope.menu"auto" | (ViewResource | MenuItemInterface)[]—The sidebar, the bottom bar and the header's tabs.
scope.labelstring—The first link of the breadcrumb: the application's name.
titleFieldstring—The field naming a record in the header and the breadcrumb.
components{ title?, actions?, navigation? }—Replace a part of the page header, or all of it.
fullWidthbooleanfalseThe whole width of the page — at the root of the resource, on views.<action> or on a layout of the list.