GrBottomNav

Пакет: @feugene/granularityядроГруппа: Навигация

Берут, когда мобильный интерфейс.

Когда брать

  • мобильный интерфейс — три-пять ключевых разделов в зоне большого пальца;
  • на широком экране панель не нужна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>

Ступень тянет три вещи сразу:

СтупеньПолосаГлифПодпись
xs48px16px--gr-text-2xs
sm56px20px--gr-text-2xs
md56px20px--gr-text-xs
lg64px24px--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

PropTypeпо умолчаниюОписание
size"xs" | "sm" | "md" | "lg" | undefinedundefinedСтупень размера: высота полосы, глиф и кегль подписи. Тач-таргет пункта остаётся 44×44 на любой ступени.
ariaLabelstring | undefinedundefinedИмя лендмарка. Не задано — берётся из локали.
asstring | Component | undefinedundefinedКомпонент ссылки для пунктов с `to`: `RouterLink`, `NuxtLink`, `Link` Inertia.
hideAbove"sm" | "md" | "lg" | "none" | undefined"sm"Брейкпоинт, начиная с которого панель скрывается. `none` — видна всегда.
position"fixed" | "static" | undefined"fixed"`static` вынимает панель из фиксированного слоя — для встраивания в макет.
modelValueобязательныйstring
itemsобязательныйGrBottomNavItem[]

Slots

SlotTypeОписание
item{ item: GrBottomNavItem; active: boolean; disabled: boolean; badgeLabel: string | undefined; }Содержимое пункта вместо иконки, подписи и счётчика. Корень пункта — тег, `aria-current`, `aria-disabled` и клик — остаётся за компонентом.

Events

EventTypeОписание
update:modelValue[value: string]

Примеры 4

Своя разметка пункта и шкала размеров

Слот item пускает в пункт что угодно — здесь аватар вместо иконки, — а size тянет высоту полосы, глиф и кегль подписи, не трогая тач-таргет.

Custom Item
<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>

Базовое переключение разделов

Базовый сценарий: иконки, счётчик на разделе и активный пункт, который отличается не только цветом.

Active section
Overview

Basic Flow
<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 меняется и снаружи компонента — из кнопок страницы; недоступный раздел остаётся виден, но не кликается.

`v-model` keeps the bottom navigation in sync with external actions, and a disabled destination stays visible without being reachable.
Current section: feed

External State
<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 и скрыта на широких экранах.

approvals
Items become real links, so a right click or “open in new tab” works as anywhere else.

Mobile Shell
<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, но из таб-порядка выпадает и не активируется

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты