GrSidebar

Package: @feugene/granularitycoreGroup: navigation

Side navigation for sections, filters and supporting actions.

Machine-translated from the Russian original, not yet reviewed. Read the original

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 screencollapsed folds the panel to an icon width without removing the navigation;
  • the sections are groupedGrSidebarGroup gives headings inside the list;
  • a landmark is neededlandmark 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

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.

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 label goes into title and aria-label.

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.

Playground 10

Loading…

Code
<GrSidebar />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
titlestring | undefinedundefined
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.
widthstring | undefined"240px"The width in the expanded state.
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.
subtitlestring | undefinedundefined
collapsedboolean | 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.*`).

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

EventTypeDescription
update:collapsed[value: boolean]

Examples 3

Basic section rail

overview
Expanded

Toggle the sidebar: collapsed items keep their icon, and «Billing» (no icon) falls back to its first letter.

Basic Sections
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrSidebar, GrSidebarGroup, GrSidebarItem } from '@feugene/granularity'

const currentSection = ref('overview')
const collapsed = ref(false)

// icon — класс UnoCSS-иконки; у «Billing» иконки нет — в свёрнутом виде покажется буква «B».
const groups = [
  {
    label: 'Workspace',
    items: [
      { label: 'Overview', value: 'overview', icon: 'i-lucide-layout-dashboard', badge: undefined as number | undefined },
      { label: 'Team', value: 'team', icon: 'i-lucide-users', badge: 4 },
    ],
  },
  {
    label: 'Administration',
    items: [
      { label: 'Billing', value: 'billing', icon: undefined, badge: undefined },
      { label: 'Settings', value: 'settings', icon: 'i-lucide-settings', badge: undefined },
    ],
  },
]
</script>

<template>
  <div class="flex min-h-[240px] gap-3">
    <GrSidebar
      v-model:collapsed="collapsed"
      title="Workspace"
      subtitle="Navigation"
      show-toggle-button
      landmark="navigation"
      aria-label="Workspace sections"
      class="rounded-xl"
    >
      <GrSidebarGroup v-for="group in groups" :key="group.label" :label="group.label">
        <div class="grid gap-1">
          <GrSidebarItem
            v-for="section in group.items"
            :key="section.value"
            :label="section.label"
            :icon="section.icon"
            :badge="section.badge"
            :active="section.value === currentSection"
            @click="currentSection = section.value"
          />
        </div>
      </GrSidebarGroup>
    </GrSidebar>

    <div class="flex-1 rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
      <div class="flex items-center justify-between gap-3">
        <div class="text-base font-semibold capitalize">
          {{ currentSection }}
        </div>
        <GrBadge tone="neutral">
          {{ collapsed ? 'Collapsed' : 'Expanded' }}
        </GrBadge>
      </div>
      <p class="mt-2 text-sm text-[var(--gr-muted-fg)]">
        Toggle the sidebar: collapsed items keep their icon, and «Billing» (no icon) falls back to its first letter.
      </p>
    </div>
  </div>
</template>

Documentation anchors

Current anchor target
API

Documentation Nav
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrBadge, GrSidebar } from '@feugene/granularity'

const currentSection = ref('api')
const sections = [
  { label: 'Overview', value: 'overview' },
  { label: 'Examples', value: 'examples' },
  { label: 'API', value: 'api' },
  { label: 'Notes', value: 'notes' },
]

const activeSection = computed(() => {
  return sections.find(section => section.value === currentSection.value)?.label ?? 'API'
})
</script>

<template>
  <div class="grid gap-3 sm:grid-cols-[260px_minmax(0,1fr)]">
    <GrSidebar title="Doc sections" subtitle="Anchored navigation">
      <div class="grid gap-2">
        <button
          v-for="section in sections"
          :key="section.value"
          type="button"
          class="flex items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors"
          :class="section.value === currentSection ? 'bg-[var(--gr-sidebar-primary)] text-[var(--gr-sidebar-primary-fg)]' : 'hover:bg-[var(--gr-sidebar-accent)] hover:text-[var(--gr-sidebar-accent-fg)]'"
          @click="currentSection = section.value"
        >
          <span>{{ section.label }}</span>
          <GrBadge size="sm" tone="neutral">
            #
          </GrBadge>
        </button>
      </div>
    </GrSidebar>

    <div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
      <div class="text-sm text-[var(--gr-muted-fg)]">
        Current anchor target
      </div>
      <div class="mt-1 text-base font-semibold">
        {{ activeSection }}
      </div>
    </div>
  </div>
</template>

Filter rail composition

Active: on Assigned: off Overdue: on

Filter Rail
<script setup lang="ts">
import { reactive } from 'vue'

import { GrBadge, GrSidebar, GrSwitch } from '@feugene/granularity'

const filters = reactive({
  active: true,
  assigned: false,
  overdue: true,
})
</script>

<template>
  <div class="grid gap-3 sm:grid-cols-[260px_minmax(0,1fr)]">
    <GrSidebar title="Filters" subtitle="Sticky control rail">
      <div class="grid gap-3">
        <label class="flex items-center justify-between gap-3 text-sm">
          Active only
          <GrSwitch v-model="filters.active" />
        </label>
        <label class="flex items-center justify-between gap-3 text-sm">
          Assigned to me
          <GrSwitch v-model="filters.assigned" />
        </label>
        <label class="flex items-center justify-between gap-3 text-sm">
          Overdue
          <GrSwitch v-model="filters.overdue" />
        </label>
      </div>
    </GrSidebar>

    <div class="rounded-xl border border-[var(--gr-brd)] bg-[var(--gr-bg)] p-4">
      <div class="flex flex-wrap gap-2">
        <GrBadge :tone="filters.active ? 'primary' : 'neutral'">
          Active: {{ filters.active ? 'on' : 'off' }}
        </GrBadge>
        <GrBadge :tone="filters.assigned ? 'primary' : 'neutral'">
          Assigned: {{ filters.assigned ? 'on' : 'off' }}
        </GrBadge>
        <GrBadge :tone="filters.overdue ? 'primary' : 'neutral'">
          Overdue: {{ filters.overdue ? 'on' : 'off' }}
        </GrBadge>
      </div>
    </div>
  </div>
</template>

Accessibility

APG pattern

Full keyboard contract of the package

Component documentationAll components