GrSidebar
Берут, когда разделов много и они постоянны.
Когда брать
- разделов много и они постоянны — админка, почта, панель управления: список всегда под рукой;
- места на экране мало —
collapsedсворачивает панель до иконочной ширины, не убирая навигацию; - разделы сгруппированы —
GrSidebarGroupдаёт заголовки внутри списка; - нужен лендмарк —
landmarkобъявляет панель ориентиромnavigationилиcomplementary.
Когда взять другое
| Нужно | Берите |
|---|---|
| Панель выезжает по требованию и закрывается | GrDrawer |
| Верхняя панель приложения | GrNavbar |
| Нижняя панель на мобильном | GrBottomNav |
| Разделы внутри одной страницы | GrTabs |
| Список действий по кнопке | GrDropdownMenu |
Лендмарк и имя
<GrSidebar landmark="navigation" aria-label="Основная навигация">
…
</GrSidebar>
Корень — <aside> (landmark="complementary", по умолчанию) или <nav>
(landmark="navigation"). Вложенного <nav> внутрь <aside> компонент не
рисует: два лендмарка на одну панель засоряют обзор диктора, а панель фильтров
навигацией не является вовсе — сторона потребителя решает, чем панель является.
ariaLabel обязателен там, где панелей на странице больше одной: без имени
лендмарки в обзоре неразличимы.
Группы секций
<GrSidebarGroup label="Администрирование">
<GrSidebarItem label="Оплата" icon="i-lucide-credit-card" />
<GrSidebarItem label="Настройки" icon="i-lucide-settings" />
</GrSidebarGroup>
Группа объявлена role="group" и связана со своим заголовком через
aria-labelledby. В свёрнутой панели заголовку негде поместиться: он скрывается,
а секции начинает разделять линия — без неё иконки соседних групп сливаются в
один столбец. Имя группы при этом не берётся из пустоты: aria-labelledby
снимается вместе с заголовком.
Сворачивание
collapsed поддерживает v-model:collapsed, панель работает и как
управляемая, и как самостоятельная. show-toggle-button добавляет кнопку;
её подпись приходит из локали (gr.sidebar.expand / gr.sidebar.collapse) и
переопределяется пропом toggleLabel.
Шеврон всегда указывает туда, куда уедет панель: у правой панели «свернуть» — это стрелка вправо.
В свёрнутом виде GrSidebarItem показывает иконку, а без неё — первую букву
метки; метка уходит в title и aria-label.
Сторона и ширина
| Проп | Что делает |
|---|---|
position | left (по умолчанию) или right: граница переезжает на внутреннюю сторону, шевроны зеркалятся |
width / collapsedWidth | ширины развёрнутого и свёрнутого состояния |
Клавиатура
Контейнер контента прокручивается и потому является остановкой Tab с видимым
фокус-кольцом: иначе панель с текстовым содержимым не проскроллить без мыши.
Недоступный пункт (disabled) перестаёт быть кнопкой — он рендерится <span>,
не ловит фокус и гасится токеном --gr-disabled-fg, а не прозрачностью.
Playground 10
Загружается…
<GrSidebar />Установка
npm i @feugene/granularityИмпорт
import { GrSidebar } from '@feugene/granularity/components/GrSidebar'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
title | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | Имя лендмарка: без него две панели на странице неразличимы. |
position | "right" | "left" | undefined | "left" | Сторона экрана: граница и направление шеврона зеркалятся. |
width | string | undefined | "240px" | Ширина в развёрнутом состоянии. |
landmark | "complementary" | "navigation" | undefined | "complementary" | Лендмарк корня. `complementary` (по умолчанию) — `<aside>`; `navigation` — `<nav>` для панели, которая действительно является навигацией. Вложенный `<nav>` внутрь `<aside>` не заводим: два лендмарка на одну панель засоряют обзор, а панель фильтров навигацией не является вовсе. |
subtitle | string | undefined | undefined | — |
collapsed | boolean | undefined | false | Свёрнутое состояние. Поддерживает `v-model:collapsed`. |
showToggleButton | boolean | undefined | false | Показать кнопку сворачивания/разворачивания в хедере. |
collapsedWidth | string | undefined | "64px" | Ширина в свёрнутом состоянии. |
toggleLabel | string | undefined | undefined | A11y-лейбл кнопки тогла. Не задан — берётся из локали (`gr.sidebar.*`). |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Содержимое панели: навигация, группы, произвольная разметка. |
title | any | Заголовок шапки вместо пропа `title`. |
subtitle | any | Подзаголовок под заголовком. |
Events
| Event | Type | Описание |
|---|---|---|
update:collapsed | [value: boolean] | — |
Примеры 3
Базовый ряд разделов
Базовый desktop-shell: GrSidebar с v-model:collapsed и show-toggle-button, а навигация собрана из GrSidebarItem (иконка, badge, active-состояние). В свёрнутом виде пункты сохраняют иконку, а «Billing» без иконки показывает первую букву.
Toggle the sidebar: collapsed items keep their icon, and «Billing» (no icon) falls back to its first letter.
<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>Якоря документации
Sidebar как rail для doc anchors: кастомные <button>-пункты с active-подсветкой через --gr-sidebar-* токены и badge-маркером якоря.
<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>Композиция панели фильтров
Sidebar как persistent container для фильтров и переключателей: набор GrSwitch, чьё состояние отражается справа через GrBadge.
<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>Доступность
- Паттерн APG
—- Клавиши
- кнопка сворачивания — обычная кнопка (
Enter/Space), фокус на ней остаётся, поэтому повторное нажатие возвращает панель. Содержимое сайдбара — скроллер и потому таб-стоп (tabindex="0")