GrBottomNav
Берут, когда мобильный интерфейс.
Когда брать
- мобильный интерфейс — три-пять ключевых разделов в зоне большого пальца;
- на широком экране панель не нужна —
hideAboveубирает её, оставляя боковую навигацию; - у раздела есть счётчик —
badgeпоказывает непрочитанное прямо на иконке; - нужен лендмарк
navigation— корень рендерится тегом<nav>.
Когда взять другое
| Нужно | Берите |
|---|---|
| Разделов больше пяти | GrSidebar / GrDrawer |
| Верхняя панель приложения | GrNavbar |
| Разделы внутри одной страницы | GrTabs |
| Панель действий, а не навигация | GrButtonGroup |
Разделы
<GrBottomNav
v-model="section"
:items="[
{ label: 'Лента', value: 'feed', icon: 'i-lucide-newspaper' },
{ label: 'Задачи', value: 'tasks', icon: 'i-lucide-check-square', badge: 12 },
{ label: 'Профиль', value: 'profile', icon: 'i-lucide-user' },
]"
/>
| Поле пункта | Что делает |
|---|---|
label, value | подпись и значение v-model |
icon | класс UnoCSS-иконки (i-lucide-*) или Vue-компонент; декоративна |
badge | счётчик поверх пункта |
badgeLabel | подпись счётчика для скринридера, сильнее локали |
disabled | раздел виден, но недостижим |
href / to | пункт становится ссылкой |
ariaLabel | доступное имя, когда подписи недостаточно |
Где пользователь
Активный пункт объявлен aria-current="page" и отличается тремя признаками
сразу: подложкой, весом подписи и цветом. Одного цвета мало — он не виден при
монохромном зрении и не существует для диктора (WCAG 1.4.1).
Лендмарк всегда назван: без имени <nav> панели неотличим от основной
навигации страницы. Дефолт берётся из локали (gr.bottomNav.label), ariaLabel
его перебивает.
role="tab" компонент не использует: нижняя навигация переключает страницы, а
не панели внутри одного экрана, а tablist без tabpanel — сломанный паттерн.
Счётчик
Число рисуется декоративно, а рядом идёт визуально скрытая подпись: голое «12»
диктору ничего не сообщает. Числовое значение озвучивается через локаль
(gr.bottomNav.badge), строковое читается как есть, badgeLabel перебивает оба.
Ссылки
<GrBottomNav v-model="section" :as="RouterLink" :items="items" />
Корневой тег пункта выбирается так: as (когда у пункта есть to) → <a href>
→ <button>. Ссылка даёт правый клик, «открыть в новой вкладке» и предзагрузку —
всё то, чего у кнопки нет. disabled делает корень <span>: недостижимый
раздел не должен оставаться в порядке Tab.
GrLink внутрь не импортируется намеренно: зависимость притащила бы потребителю
его CSS ради одного тега.
Видимость и слой
<GrBottomNav v-model="section" :items="items" hide-above="none" position="static" />
hideAbove(smпо умолчанию, а такжеmd,lg,none) — брейкпоинт, с которого панель скрывается.noneнужен киоску и PWA, где нижняя навигация постоянна;position(fixedпо умолчанию,static) —staticвынимает панель из фиксированного слоя и кладёт обычным блоком в поток: так её можно встроить в макет.
В режиме fixed слой — --gr-z-bottom-nav (850): самый низкий в шкале, поэтому
открытая выпадашка, тултип или модалка панель перекрывают. Подробности —
../z-index.md.
Нижний отступ учитывает env(safe-area-inset-bottom) — на телефоне с жестовой
полосой подписи не уезжают под неё.
Состояния
disabled гасит пункт токеном --gr-disabled-fg, а не прозрачностью: opacity
разбавляет выверенные на AA цвета текста.
Размер
<GrBottomNav v-model="section" :items="items" size="lg" />
<GrConfigProvider size="sm">
…
</GrConfigProvider>
Ступень тянет три вещи сразу:
| Ступень | Полоса | Глиф | Подпись |
|---|---|---|---|
xs | 48px | 16px | --gr-text-2xs |
sm | 56px | 20px | --gr-text-2xs |
md | 56px | 20px | --gr-text-xs |
lg | 64px | 24px | --gr-text-sm |
Чего ступень не трогает — размер самого пункта: min-width и min-height
остаются 44px на любой ступени. Тач-таргет мельче 44×44 — это провал WCAG 2.5.5,
и «сделать панель компактнее» не повод в него провалиться. Практическое
следствие: xs ужимает полосу за счёт воздуха вокруг содержимого, а не за счёт
области, в которую целится палец.
Значение приходит по общему правилу: локальный проп → componentDefaults →
глобальный size провайдера → md (см. ../sizes.md).
Свой пункт
Иконка, подпись и счётчик покрывают не всё: в мессенджере на месте иконки
профиля стоит аватар. Слот item отдаёт содержимое пункта целиком:
<GrBottomNav v-model="section" :items="items">
<template #item="{ item, active, disabled, badgeLabel }">
<GrAvatar v-if="item.value === 'me'" :size="24" alt="Ann Lee" />
<span v-else :class="item.icon" class="block h-5 w-5" aria-hidden="true" />
<span class="truncate leading-none">{{ item.label }}</span>
</template>
</GrBottomNav>
Слот меняет содержимое, а не поведение: выбор тега (button / a /
компонент из as), aria-current, aria-disabled и обработка клика остаются
за компонентом. Поэтому кастомный пункт не может случайно перестать быть
навигацией.
Забирая разметку, вы забираете и озвучку счётчика: визуально скрытая подпись
рисуется вместе с бейджем. Готовая строка приходит слотом (badgeLabel) — её
достаточно положить в sr-only, локаль и badgeLabel пункта уже учтены.
Playground 5
Загружается…
<GrBottomNav />Установка
npm i @feugene/granularityИмпорт
import { GrBottomNav } from '@feugene/granularity/components/GrBottomNav'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Ступень размера: высота полосы, глиф и кегль подписи. Тач-таргет пункта остаётся 44×44 на любой ступени. |
ariaLabel | string | undefined | undefined | Имя лендмарка. Не задано — берётся из локали. |
as | string | Component | undefined | undefined | Компонент ссылки для пунктов с `to`: `RouterLink`, `NuxtLink`, `Link` Inertia. |
hideAbove | "sm" | "md" | "lg" | "none" | undefined | "sm" | Брейкпоинт, начиная с которого панель скрывается. `none` — видна всегда. |
position | "fixed" | "static" | undefined | "fixed" | `static` вынимает панель из фиксированного слоя — для встраивания в макет. |
modelValueобязательный | string | — | — |
itemsобязательный | GrBottomNavItem[] | — | — |
Slots
| Slot | Type | Описание |
|---|---|---|
item | { item: GrBottomNavItem; active: boolean; disabled: boolean; badgeLabel: string | undefined; } | Содержимое пункта вместо иконки, подписи и счётчика. Корень пункта — тег, `aria-current`, `aria-disabled` и клик — остаётся за компонентом. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: string] | — |
Примеры 4
Своя разметка пункта и шкала размеров
Слот item пускает в пункт что угодно — здесь аватар вместо иконки, — а size тянет высоту полосы, глиф и кегль подписи, не трогая тач-таргет.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrAvatar, GrBottomNav, GrSegmented } from '@feugene/granularity'
import type { GrComponentSize } from '@feugene/granularity'
const section = ref('chats')
const size = ref<GrComponentSize>('md')
const sizes = [
{ value: 'xs', label: 'XS' },
{ value: 'sm', label: 'SM' },
{ value: 'md', label: 'MD' },
{ value: 'lg', label: 'LG' },
]
const items = [
{ label: 'Chats', value: 'chats', icon: 'i-lucide-message-circle', badge: 5 },
{ label: 'Calls', value: 'calls', icon: 'i-lucide-phone' },
{ label: 'Ann Lee', value: 'me' },
]
// Забрав разметку пункта, размер глифа компонент за вас уже не считает.
const glyphClass = computed(() => ({ xs: 'h-4 w-4', sm: 'h-5 w-5', md: 'h-5 w-5', lg: 'h-6 w-6' })[size.value])
const avatarSize = computed(() => ({ xs: 20, sm: 24, md: 24, lg: 28 })[size.value])
</script>
<template>
<div class="grid gap-4">
<GrSegmented
:model-value="size"
:options="sizes"
size="sm"
class="justify-self-start"
@update:model-value="size = $event as GrComponentSize"
/>
<GrBottomNav
v-model="section"
:items="items"
:size="size"
position="static"
hide-above="none"
>
<template #item="{ item, badgeLabel }">
<GrAvatar
v-if="item.value === 'me'"
:size="avatarSize"
alt="Ann Lee"
>
AL
</GrAvatar>
<span
v-else-if="item.icon"
:class="[item.icon, glyphClass]"
class="block shrink-0"
aria-hidden="true"
/>
<span class="max-w-full truncate leading-none">{{ item.label }}</span>
<template v-if="item.badge">
<span
class="absolute right-1 top-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-[var(--gr-radius-full)] bg-[var(--gr-danger)] px-1 text-[length:var(--gr-text-2xs)] font-700 text-[var(--gr-danger-fg)]"
aria-hidden="true"
>{{ item.badge }}</span>
<span class="sr-only">{{ badgeLabel }}</span>
</template>
</template>
</GrBottomNav>
</div>
</template>Базовое переключение разделов
Базовый сценарий: иконки, счётчик на разделе и активный пункт, который отличается не только цветом.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBottomNav, GrCard } from '@feugene/granularity'
const currentSection = ref('overview')
const items = [
{ label: 'Overview', value: 'overview', icon: 'i-lucide-layout-dashboard' },
{ label: 'Invoices', value: 'invoices', icon: 'i-lucide-receipt', badge: 3 },
{ label: 'Team', value: 'team', icon: 'i-lucide-users' },
]
const activeLabel = computed(() => {
return items.find(item => item.value === currentSection.value)?.label ?? 'Overview'
})
</script>
<template>
<div class="grid gap-4">
<GrCard class="p-4">
<div class="text-sm text-[var(--gr-muted-fg)]">
Active section
</div>
<div class="text-base font-semibold">
{{ activeLabel }}
</div>
</GrCard>
<GrBottomNav
v-model="currentSection"
:items="items"
position="static"
hide-above="none"
/>
</div>
</template>Синхронизация с внешним состоянием
v-model меняется и снаружи компонента — из кнопок страницы; недоступный раздел остаётся виден, но не кликается.
<script setup lang="ts">
import { ref } from 'vue'
import { GrBottomNav, GrButton, GrCard } from '@feugene/granularity'
const currentSection = ref('feed')
const items = [
{ label: 'Feed', value: 'feed', icon: 'i-lucide-newspaper' },
{ label: 'Tasks', value: 'tasks', icon: 'i-lucide-check-square', badge: 12 },
{ label: 'Billing', value: 'billing', icon: 'i-lucide-credit-card', disabled: true },
{ label: 'Profile', value: 'profile', icon: 'i-lucide-user' },
]
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap gap-2">
<GrButton size="sm" variant="outline" @click="currentSection = 'feed'">
Open feed
</GrButton>
<GrButton size="sm" variant="outline" @click="currentSection = 'tasks'">
Jump to tasks
</GrButton>
<GrButton size="sm" variant="outline" @click="currentSection = 'profile'">
Focus profile
</GrButton>
</div>
<GrCard class="p-4">
<div class="text-sm text-[var(--gr-muted-fg)]">
`v-model` keeps the bottom navigation in sync with external actions, and a disabled
destination stays visible without being reachable.
</div>
<div class="mt-2 text-base font-semibold capitalize">
Current section: {{ currentSection }}
</div>
</GrCard>
<GrBottomNav
v-model="currentSection"
:items="items"
position="static"
hide-above="none"
/>
</div>
</template>Композиция мобильной оболочки
Пункты-ссылки внутри мобильного макета: правый клик и «открыть в новой вкладке» работают как везде. В приложении панель обычно fixed и скрыта на широких экранах.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrBottomNav, GrCard } from '@feugene/granularity'
const currentSection = ref('approvals')
const items = [
{ label: 'Approvals', value: 'approvals', icon: 'i-lucide-check-check', href: '#approvals' },
{ label: 'Calendar', value: 'calendar', icon: 'i-lucide-calendar', href: '#calendar' },
{ label: 'Settings', value: 'settings', icon: 'i-lucide-settings', href: '#settings' },
]
const sectionDescriptions: Record<string, string> = {
approvals: 'Items become real links, so a right click or “open in new tab” works as anywhere else.',
calendar: 'The bar keeps the current destination announced as the current page, not just coloured.',
settings: 'In a real app the bar is fixed to the bottom edge and hidden on wide screens by default.',
}
const sectionDescription = computed(() => {
return sectionDescriptions[currentSection.value] ?? sectionDescriptions.approvals
})
</script>
<template>
<div class="mx-auto grid max-w-80 gap-0 overflow-hidden rounded-3xl border border-[var(--gr-brd)]">
<GrCard class="rounded-none border-0 p-4">
<div class="text-base font-semibold capitalize">
{{ currentSection }}
</div>
<div class="mt-1 text-sm text-[var(--gr-muted-fg)]">
{{ sectionDescription }}
</div>
</GrCard>
<GrBottomNav
v-model="currentSection"
:items="items"
position="static"
hide-above="none"
aria-label="Mobile shell sections"
/>
</div>
</template>Доступность
- Паттерн APG
—- Клавиши
- пункты — кнопки или ссылки, каждая своя остановка
Tab,Enter/Space— перейти. Выключенный пункт рендерится<span>: он виден и объявлен черезaria-disabled, но из таб-порядка выпадает и не активируется