GrSidebar
Side navigation for sections, filters and supporting actions.
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 —
collapsedfolds the panel to an icon width without removing the navigation; - the sections are grouped —
GrSidebarGroupgives headings inside the list; - a landmark is needed —
landmarkdeclares the panel anavigationorcomplementarylandmark.
When to take something else
| Need | Take |
|---|---|
| The panel slides out on demand and closes | GrDrawer |
| The top bar of the application | GrNavbar |
| The bottom bar on mobile | GrBottomNav |
| Sections inside a single page | GrTabs |
| A list of actions behind a button | GrDropdownMenu |
Playground controls come from the API, not from hand
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
<GrSidebar>Label</GrSidebar>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>, andaria-labeltells 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 — withv-model:expanded. Folded, the branch shows itself active when one of its pages is open; the integration not on this plan isdisabledand skipped by Tab.Overdue invoices4 invoices, €3,912.40 — the oldest 23 days lateA rail that gives the room back
An agent works in the conversation, not in the menu:
v-model:collapsedfolds the panel tocollapsed-width, andshow-toggle-buttonputs the switch in the header with atoggle-labelof its own. Every item keeps its name — inaria-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 conversationsExport stops at 50,000 rowsAdd a second billing contactReport filters on the right
Filters are not navigation: the root stays an
<aside>, the defaultcomplementarylandmark.position="right"moves the border to the inner side, thetitleslot carries the number of narrowed filters and thesubtitleslot 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 segmentsTeam · EU€18.9kTeam · UK€7.3kBusiness · EU€9.8k
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
title | string | undefined | undefined | The heading of the header — the name of the product or the section. Hidden in the collapsed panel; the `#title` slot is stronger. |
openv-model | boolean | undefined | false | Whether the layer is open. Supports `v-model:open`; without `overlay` it means nothing. |
ariaLabel | string | undefined | undefined | The 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. |
width | string | undefined | "240px" | The width in the expanded state. |
subtitle | string | undefined | undefined | A muted line above the heading — a workspace, for instance. The `#subtitle` slot is stronger. |
collapsedv-model | boolean | undefined | false | The collapsed state. It supports `v-model:collapsed`. |
showToggleButton | boolean | undefined | false | Show the collapse/expand button in the header. |
collapsedWidth | string | undefined | "64px" | The width in the collapsed state. |
toggleLabel | string | undefined | undefined | The a11y label of the toggle button. Unset — it is taken from the locale (`gr.sidebar.*`). |
overlay | boolean | undefined | false | The 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
| Slot | Type | Description |
|---|---|---|
default | any | The content of the panel: navigation, groups, arbitrary markup. |
title | any | The heading of the header instead of the `title` prop. |
subtitle | any | A subheading under the heading. |
Events
| Event | Signature | Description |
|---|---|---|
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 type { GrSidebarProps, GrSidebarEmits, GrSidebarGroupProps } from '@feugene/granularity/components/GrSidebar'Filedist/types/components/GrSidebar/GrSidebar.vue.d.ts
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;
}interface GrSidebarEmits {
(e: 'update:collapsed', value: boolean): void;
(e: 'update:open', value: boolean): void;
}Filedist/types/components/GrSidebar/GrSidebarGroup.vue.d.ts
interface GrSidebarGroupProps {
label?: string;
}Filedist/types/components/GrSidebar/config.d.ts
const grSidebarConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSidebar">;Filedist/types/components/GrSidebar/grSidebarStyles.d.ts
type GrSidebarPosition = typeof GR_SIDEBAR_POSITIONS[number];type GrSidebarLandmark = typeof GR_SIDEBAR_LANDMARKS[number];Filedist/types/components/GrSidebar/safelist.d.ts
const grSidebarSafelist: string[];Filedist/types/components/GrSidebar/GrSidebarItem.vue.d.ts
interface GrSidebarItemProps {
label: string;
icon?: string | Component;
href?: string;
as?: string | Component;
active?: boolean;
disabled?: boolean;
badge?: string | number;
expanded?: boolean;
defaultExpanded?: boolean;
}interface GrSidebarItemEmits {
(e: 'update:expanded', value: boolean): void;
}Accessibility
APG pattern
—
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
The landmark and the name
<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:
<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
<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.
<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
widthand the title; - the button in the header closes rather than collapses: there is nothing to collapse there,
and otherwise closing is possible only with
Escor the backdrop. And it is named accordingly — “Close sidebar” (gr.sidebar.close) rather than “Collapse sidebar”;toggleLabelis 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:
<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
| Prop | What it does |
|---|---|
position | left (the default) or right: the border moves to the inner side, the chevrons are mirrored |
width / collapsedWidth | the 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.
Props in this example