GrSidebar

  • core
  • navigation
  • API · 12 props
  • ⌨ keys

Side navigation for sections, filters and supporting actions.

import { GrSidebar } from '@feugene/granularity/components/GrSidebar'

Overview

When to take it

  • there are many sections and they are permanent — an admin area, mail, a control panel: the list is always at hand;
  • there is little room on the screen — collapsed folds the panel to an icon width without removing the navigation;
  • the sections are grouped — GrSidebarGroup gives headings inside the list;
  • a landmark is needed — landmark declares the panel a navigation or complementary landmark.

When to take something else

NeedTake
The panel slides out on demand and closesGrDrawer
The top bar of the applicationGrNavbar
The bottom bar on mobileGrBottomNav
Sections inside a single pageGrTabs
A list of actions behind a buttonGrDropdownMenu

Playground controls come from the API, not from hand

Stage

This component lives above the screen, not inside a canvas: a screen opens it, and the playground has nothing to open it from. The examples below show it at work.

0 changed· state is in the URL

Examples 4 each with a live demo and its code

  • The navigation of a billing back office

    The panel really is navigation, so landmark="navigation" makes the root a <nav>, and aria-label tells it from other landmarks. Groups name runs of items; «Invoices» holds sub-items and becomes a branch — a button that opens the subtree, never a link — with v-model:expanded. Folded, the branch shows itself active when one of its pages is open; the integration not on this plan is disabled and skipped by Tab.

    Overdue invoices4 invoices, €3,912.40 — the oldest 23 days late
  • A rail that gives the room back

    An agent works in the conversation, not in the menu: v-model:collapsed folds the panel to collapsed-width, and show-toggle-button puts the switch in the header with a toggle-label of its own. Every item keeps its name — in aria-label, and as a tooltip on hover and on focus; «Refunds» has no icon and shows its first letter. Group titles give way to dividers.

    2 conversations
    Export stops at 50,000 rows
    Add a second billing contact
  • Report filters on the right

    Filters are not navigation: the root stays an <aside>, the default complementary landmark. position="right" moves the border to the inner side, the title slot carries the number of narrowed filters and the subtitle slot names the report above it. The content is plain markup — groups of checkboxes — and scrolls on its own.

    MRR by plan€36.0k across 3 segments
    Team · EU€18.9k
    Team · UK€7.3k
    Business · EU€9.8k
  • The same menu on a phone

    There is no room for a column, so overlay turns the panel into a modal layer: a backdrop, Esc, a focus trap, a locked page scroll and focus back on «Menu». When to switch is the application’s call — the package has no breakpoints, and matchMedia in setup would break hydration. In a layer the header button closes rather than folds.

    Overdue invoices
    4 invoices, €3,912.40 — the oldest 23 days late

API generated from the package · cannot drift

Props

PropTypedefaultDescription
titlestring | undefinedundefinedThe heading of the header — the name of the product or the section. Hidden in the collapsed panel; the `#title` slot is stronger.
openv-modelboolean | undefinedfalseWhether the layer is open. Supports `v-model:open`; without `overlay` it means nothing.
ariaLabelstring | undefinedundefinedThe name of the landmark: without it two panels on a page are indistinguishable.
position"right" | "left" | undefined"left"The side of the screen: the border and the direction of the chevron are mirrored.
landmark"complementary" | "navigation" | undefined"complementary"The landmark of the root. `complementary` (the default) is an `<aside>`; `navigation` is a `<nav>` for a panel that really is navigation. A nested `<nav>` inside an `<aside>` is not introduced: two landmarks for one panel clutter the overview, and a panel of filters is not navigation at all.
widthstring | undefined"240px"The width in the expanded state.
subtitlestring | undefinedundefinedA muted line above the heading — a workspace, for instance. The `#subtitle` slot is stronger.
collapsedv-modelboolean | undefinedfalseThe collapsed state. It supports `v-model:collapsed`.
showToggleButtonboolean | undefinedfalseShow the collapse/expand button in the header.
collapsedWidthstring | undefined"64px"The width in the collapsed state.
toggleLabelstring | undefinedundefinedThe a11y label of the toggle button. Unset — it is taken from the locale (`gr.sidebar.*`).
overlayboolean | undefinedfalseThe panel as a modal layer on top of the page: a backdrop, `Esc`, a focus trap, blocking of the scrolling. For a narrow screen where there is no room for a column. When to switch it on is decided by the application: the package has no breakpoint system of its own, and the environment cannot be asked in `setup` — the first client render would diverge from the server one and break hydration.

Slots

SlotTypeDescription
defaultanyThe content of the panel: navigation, groups, arbitrary markup.
titleanyThe heading of the header instead of the `title` prop.
subtitleanyA subheading under the heading.

Events

EventSignatureDescription
update:open(event: "update:open", value: boolean): void—
update:collapsed(event: "update:collapsed", value: boolean): void—

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrSidebarProps, GrSidebarEmits, GrSidebarGroupProps } from '@feugene/granularity/components/GrSidebar'

Filedist/types/components/GrSidebar/GrSidebar.vue.d.ts

GrSidebarPropsinterface
interface GrSidebarProps {
    title?: string;
    subtitle?: string;
    collapsed?: boolean;
    showToggleButton?: boolean;
    width?: string;
    collapsedWidth?: string;
    toggleLabel?: string;
    position?: GrSidebarPosition;
    landmark?: GrSidebarLandmark;
    ariaLabel?: string;
    overlay?: boolean;
    open?: boolean;
}
GrSidebarEmitsinterface
interface GrSidebarEmits {
    (e: 'update:collapsed', value: boolean): void;
    (e: 'update:open', value: boolean): void;
}

Filedist/types/components/GrSidebar/GrSidebarGroup.vue.d.ts

GrSidebarGroupPropsinterface
interface GrSidebarGroupProps {
    label?: string;
}

Filedist/types/components/GrSidebar/config.d.ts

grSidebarConfigconst
const grSidebarConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSidebar">;

Filedist/types/components/GrSidebar/grSidebarStyles.d.ts

GrSidebarPositiontype
type GrSidebarPosition = typeof GR_SIDEBAR_POSITIONS[number];
GrSidebarLandmarktype
type GrSidebarLandmark = typeof GR_SIDEBAR_LANDMARKS[number];

Filedist/types/components/GrSidebar/safelist.d.ts

grSidebarSafelistconst
const grSidebarSafelist: string[];

Filedist/types/components/GrSidebar/GrSidebarItem.vue.d.ts

GrSidebarItemPropsinterface
interface GrSidebarItemProps {
    label: string;
    icon?: string | Component;
    href?: string;
    as?: string | Component;
    active?: boolean;
    disabled?: boolean;
    badge?: string | number;
    expanded?: boolean;
    defaultExpanded?: boolean;
}
GrSidebarItemEmitsinterface
interface GrSidebarItemEmits {
    (e: 'update:expanded', value: boolean): void;
}

Accessibility

APG pattern

—

Usage from the library prose

The landmark and the name
vue
<GrSidebar landmark="navigation" aria-label="Main navigation">
  …
</GrSidebar>

The root is an <aside> (landmark="complementary", the default) or a <nav> (landmark="navigation"). The component does not draw a nested <nav> inside the <aside>: two landmarks for one panel clutter the overview of a screen reader, and a filter panel is not navigation at all — it is the consumer’s side that decides what the panel is.

ariaLabel is mandatory where there is more than one panel on the page: without a name the landmarks in the overview are indistinguishable.

The header

The header is drawn when there is something to show in it: a title or a subtitle — through the title/subtitle prop or through the #title/#subtitle slot — or the showToggleButton button. The slot is stronger than the prop, and a slot alone is enough:

vue
<template>
  <GrSidebar position="right" aria-label="Report filters">
    <template #title>
      Filters <GrBadge>2</GrBadge>
    </template>
    <template #subtitle>
      Applied to every chart
    </template>
  </GrSidebar>
</template>
Groups of sections
vue
<GrSidebarGroup label="Administration">
  <GrSidebarItem label="Billing" icon="i-lucide-credit-card" />
  <GrSidebarItem label="Settings" icon="i-lucide-settings" />
</GrSidebarGroup>

A group is declared role="group" and is linked to its heading through aria-labelledby. In a collapsed panel there is nowhere for the heading to fit: it is hidden, and the sections begin to be separated by a line — without it the icons of neighbouring groups merge into a single column. The name of the group is not taken out of nothing in the process: aria-labelledby is removed together with the heading.

Collapsing

collapsed supports v-model:collapsed, and the panel works both as controlled and as self-contained. show-toggle-button adds the button; its label comes from the locale (gr.sidebar.expand / gr.sidebar.collapse) and is overridden with the toggleLabel prop.

The chevron always points where the panel will go: on a right-hand panel “collapse” is an arrow pointing right.

When collapsed, a GrSidebarItem shows the icon, and without one the first letter of the label. The item gets its name from aria-label, and the label is shown by a GrTooltip — at the side of the panel, in its direction: to the right for a left-hand one, to the left for a right-hand one.

The tooltip also appears on focus, not only on hover: otherwise there was no way to tab through a collapsed panel and know where you are — a native title does not answer the keyboard at all.

The modal layer on a narrow screen

overlay turns the panel into a modal layer: a backdrop, Esc, a focus trap, scroll locking, the return of focus to the element that opened it. All of that is taken from the shared useModalOverlay that GrDrawer and GrPopover live by — the panel has no copy of its own.

vue
<GrSidebar v-model:open="menu" :overlay="isNarrow" aria-label="Sections">
  …
</GrSidebar>

When to switch it on is decided by the application. The package has no breakpoint system of its own, and asking the environment in setup is not allowed: matchMedia gives different answers on the server and in the first client render, and the hydration diverges. The application knows its breakpoints and passes the ready answer as a prop.

The markup of the panel is one for both modes. The wrapper of the layer is always drawn, but outside overlay it is display: contents and disappears from the layout. Had we duplicated the panel for the two modes, the copies would have diverged silently. Hence a consequence: the root of the component is that wrapper, so the consumer’s attributes go to the panel itself by an explicit v-bind rather than by inheritance.

What changes in the layer:

  • collapsing is ignored — a rail the width of an icon inside a modal window is half the screen spent on emptiness; the panel keeps its full width and the title;
  • the button in the header closes rather than collapses: there is nothing to collapse there, and otherwise closing is possible only with Esc or the backdrop. And it is named accordingly — “Close sidebar” (gr.sidebar.close) rather than “Collapse sidebar”; toggleLabel is stronger;
  • before mounting the layer is not drawn at all. The teleport switches on only after mounting (otherwise the first client render would diverge from the server one), and the panel must not flash in the flow of the page in that frame.
Nested items

Sub-items are put into the item itself — and it becomes an expandable branch:

vue
<GrSidebarItem label="Billing" icon="i-lucide-credit-card" default-expanded>
  <GrSidebarItem label="Invoices" />
  <GrSidebarItem label="Plan" />
</GrSidebarItem>

The depth is counted by the markup, not by a prop. Every item declares to its children a level one greater than its own, so a moved branch does not need renumbering: the levels are recomputed by themselves. The indentation is set on the whole row, not on its text — had we shifted only the label, the active background would have run under the neighbouring level. The step is configured with the --gr-sidebar-nest-indent token.

Expansion lives locally (default-expanded sets the initial state), and v-model:expanded takes it outside.

A branch cannot be a link at the same time. One press is not able both to lead to a page and to expand a subtree, so the sub-items are stronger: an item with them becomes a <button aria-expanded>, and href and as are not used. In the dev build the console warns about that — navigation silently removed is indistinguishable by behaviour from navigation forgotten.

A collapsed subtree is not drawn rather than hidden: an invisible branch would catch Tab and be read by the screen reader contrary to aria-expanded="false".

In the collapsed rail there are no sub-items

They do not fit into sixty-four pixels, and a flyout submenu is a different, far larger story. So the rail shows only the top level, and a press on a branch first gives the panel its width back and expands it: otherwise it would be a button from which nothing happens.

Hence a consequence for the consumer: while the panel is collapsed, an active sub-item does not show itself in any way. If that matters, mark its branch active as well.

The side and the width
PropWhat it does
positionleft (the default) or right: the border moves to the inner side, the chevrons are mirrored
width / collapsedWidththe widths of the expanded and the collapsed state
The keyboard

The container of the content scrolls and is therefore a Tab stop with a visible focus ring: otherwise a panel with text content cannot be scrolled without a mouse.

An unavailable item (disabled) stops being a button — it renders as a <span>, does not catch the focus and is dimmed with the --gr-disabled-fg token rather than with transparency.