GrTabs

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

Берут, когда разделы с разным содержимым.

Когда брать

  • разделы с разным содержимым — профиль, безопасность, уведомления: одновременно нужен один;
  • у вкладки есть счётчик или иконкаbadge и icon без своей разметки;
  • вкладки закрываютсяclosable для рабочих областей и открытых документов;
  • вкладок много — они уезжают в прокрутку, а не ломают раскладку.

Когда взять другое

НужноБерите
Переключается вид одного и того жеGrSegmented
Разделы видны все сразуGrCollapse
Разделы формы идут подрядGrFormSection
Переход ведёт на другой экранGrLink / GrSidebar
Нужны сами панелиGrTabPanels

Вид ряда

variantЧто это
pills (по умолчанию)вкладки-таблетки в общей обойме; имя общее с GrSegmented
lineклассический ряд с подчёркиванием активной вкладки

variant читается из GrConfigProvider (componentDefaults.GrTabs.variant) — вид задаётся один раз на приложение.

Высота вкладки повторяет шкалу GrButton — 28 / 32 / 40 / 44 px для xs / sm / md / lg: вкладки часто стоят с кнопкой в один ряд, и разница в несколько пикселей там читается как ошибка вёрстки. Совпадение закреплено гейтом src/__tests__/componentSize.test.ts, потому что однажды оно уже разъехалось молча.

Отсюда же следствие: size="lg" даёт 44px — размер, которого требуют WCAG 2.5.5 и Apple HIG для цели под палец.

Содержимое вкладки

Поле icon у вкладки — иконка слева от подписи; она декоративна (aria-hidden), имя вкладке даёт текст. Иконка задаётся Vue-компонентом либо классом иконки вашей UnoCSS-сборки (i-lucide-* — тогда нужен ваш presetIcons, см. «Иконки»).

Слот #tab заменяет содержимое целиком и получает { tab, active, disabled }:

<GrTabs v-model="tab" :tabs="tabs">
  <template #tab="{ tab, active }">
    <GrAvatar :src="tab.avatar" size="xs" />
    <span :class="active ? 'font-700' : ''">{{ tab.label }}</span>
  </template>
</GrTabs>

Закрываемые вкладки

<GrTabs v-model="tab" :tabs="tabs" closable @close="close" />

closable включает закрытие для всего ряда; closable: false у отдельной вкладки его снимает (закреплённая вкладка среди закрываемых), а closable: true у вкладки включает её одну, когда пропа ряда нет. Отключённая вкладка не закрывается ничем: aria-disabled — это про всё взаимодействие, а закрытие тоже взаимодействие.

Крестик — не кнопка. role="tab" объявляет своих потомков презентационными, поэтому вложенная <button> теряется у скринридера (axe: nested-interactive). Крестик — aria-hidden-<span>, а клик по нему разбирает обработчик самой вкладки: интерактивный элемент в ряду по-прежнему один. С клавиатуры закрытие — Delete или Backspace на вкладке под фокусом; о том, что клавиша работает, сообщает aria-keyshortcuts="Delete" на закрываемых вкладках.

Список остаётся у потребителя. Компонент эмитит только close(value) и не трогает ни tabs, ни modelValue: закрытие может не состояться («сохранить изменения?»), и вкладка успела бы переключиться зря.

function close(value: string) {
  const index = tabs.value.findIndex(tab => tab.value === value)
  tabs.value.splice(index, 1)

  if (current.value === value)
    current.value = tabs.value[index]?.value ?? tabs.value[index - 1]?.value ?? ''
}

Фокус после закрытия компонент возвращает сам — но только когда список действительно укоротился и только если закрытая вкладка держала фокус. Иначе клавиатурное закрытие роняло бы фокус в <body> вместе с исчезнувшей кнопкой.

Слот #tab крестик не отнимает: он живёт снаружи слота, поэтому своя разметка вкладки не теряет закрытие.

Пустой ряд

Когда tabs пуст, role="tablist" не рендерится вовсе — на его месте блок с текстом из локали (gr.tabs.empty). Роль обязана владеть потомками tab, и текстовый узел внутри неё — нарушение aria-required-children, а не пустое состояние. Блок держит высоту вкладки, чтобы соседние блоки не прыгали, когда закрыли последнюю.

<GrTabs v-model="tab" :tabs="tabs" empty-text="Нет открытых файлов" />

emptyText перекрывает текст локали, слот #empty — оба.

Переполнение

Горизонтальный ряд прокручивается, а не переносится строкой: перенос ломает ряд и уводит вкладки под панель. Полоса прокрутки скрыта — ряд ведут стрелки, а активная вкладка сама подтягивается в видимую часть, в том числе когда её выбрали снаружи.

Продолжение за краем выдаёт затухание: гаснет тот край, за которым ещё есть вкладки — в начале ряда правый, в конце левый, в середине оба. Влезает целиком — не гаснет вовсе. Без этого признака вкладки за краем оставались достижимыми, но узнать, что они там есть, было нечем.

Затухание сделано маской, а не градиентом-подложкой, и это следствие того, что фон полосы у вариантов разный: pills несёт свой непрозрачный --gr-muted, а line прозрачна и лежит на фоне родителя, которого компонент не знает. Градиенту неоткуда взять цвет подложки для line; маска гасит содержимое независимо от того, что под ним.

Ширина затухания — хук --gr-tabs-scroll-fade (по умолчанию 1.5rem). Тем же значением задан scroll-padding ряда: вкладка, подтянутая в видимую часть, не встаёт под затухание, и кольцо фокуса остаётся читаемым.

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

Отдельный tabindex контейнеру не нужен: вкладки внутри достижимы стрелками, и фокус прокручивает ряд за собой — правило «скроллящийся блок достижим с клавиатуры» выполняется через них, а не через остановку таб-порядка на самом tablist.

Режим активации

<GrTabs v-model="tab" :tabs="tabs" activation-mode="manual" />

automatic (по умолчанию) — стрелка сразу переключает вкладку: выбор следует за фокусом, как требует APG. manual — стрелка двигает только фокус, выбор подтверждается Enter или Space. Второй режим нужен вкладкам с тяжёлой загрузкой: перебор стрелками иначе тянет каждую панель.

Ориентация

orientation="vertical" разворачивает список в колонку, объявляет aria-orientation и переводит навигацию на / вместо /.

Отключённая вкладка остаётся видимой

Нативный disabled убирал вкладку и из таб-порядка, и из объявления скринридером — пользователь не узнавал о её существовании. APG для tablist рекомендует обратное: вкладка остаётся достижимой и объявленной, а недоступность выражает aria-disabled. Выбор она при этом не принимает и пропускается стрелками.

Связка с панелями

idBase задаёт схему id: вкладка получает <idBase>-tab-<value> и aria-controls="<idBase>-panel-<value>". Тот же idBase передаётся в GrTabPanels — иначе связка aria-controlsaria-labelledby не сойдётся (в dev-сборке панель об этом предупредит).

Почему фокус не теряется

Массив ссылок на кнопки чистится, когда Vue отдаёт null для исчезнувшего узла, и обрезается по длине списка вкладок. Без этого в нём оставались отсоединённые от DOM кнопки, и focus() по сократившемуся списку молча проваливался в <body>.

Playground 6

Загружается…

Код
<GrTabs />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
variant"pills" | "line" | undefinedundefinedВид ряда: обойма с таблетками или ряд с подчёркиванием.
closableboolean | undefinedfalseКрестик на вкладках и закрытие по `Delete`/`Backspace`. Точечно снимается `closable: false` у самой вкладки.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
orientationGrTabsOrientation | undefined"horizontal"Горизонтальный (по умолчанию) или вертикальный список вкладок.
emptyTextstring | undefinedundefinedТекст, когда список вкладок пуст. Слот `#empty` сильнее.
idBasestring | undefinedundefinedБаза id для ARIA-связки с `GrTabPanels`. Если задана, каждая вкладка получает `id="<idBase>-tab-<value>"` и `aria-controls="<idBase>-panel-<value>"`. Передайте тот же `idBase` в `GrTabPanels`, чтобы связать `tab`↔`tabpanel`.
activationModeGrTabsActivationMode | undefined"automatic"`automatic` (по умолчанию) — стрелка сразу переключает вкладку; `manual` — стрелка двигает только фокус, выбор подтверждается `Enter`/`Space`. Второй режим для вкладок с тяжёлой загрузкой: перебор стрелками иначе тянет каждую панель.
modelValueобязательныйstring
tabsобязательныйGrTab[]

Slots

SlotTypeОписание
tab{ tab: GrTab; active: boolean; disabled: boolean; }Содержимое вкладки целиком — вместо подписи, иконки и счётчика. Крестик остаётся.
emptyanyПустой ряд — вместо текста из локали.

Events

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

Примеры 7

Режим активации и вертикальные вкладки

activationMode="manual" двигает стрелками только фокус, orientation="vertical" разворачивает список в колонку.

Панель «Обзор»
В ручном режиме стрелки двигают только фокус — выбор подтверждает `Enter` или `Space`. Отключённая вкладка остаётся объявленной, но пропускается при переборе.

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

import type { GrTabsOrientation } from '@feugene/granularity'
import { GrSegmented, GrTabPanel, GrTabPanels, GrTabs } from '@feugene/granularity'

const tab = ref('overview')
const orientation = ref<GrTabsOrientation>('horizontal')
const manual = ref(true)

const tabs = [
  { value: 'overview', label: 'Обзор' },
  { value: 'activity', label: 'Активность', badge: '12' },
  { value: 'archive', label: 'Архив', disabled: true },
  { value: 'billing', label: 'Счета' },
]
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-4">
      <GrSegmented
        v-model="orientation"
        size="sm"
        :options="[
          { value: 'horizontal', label: 'horizontal' },
          { value: 'vertical', label: 'vertical' },
        ]"
      />
      <label class="flex items-center gap-2 text-sm text-[var(--gr-muted-fg)]">
        <input v-model="manual" type="checkbox">
        activationMode="manual"
      </label>
    </div>

    <div class="flex flex-wrap items-start gap-4">
      <GrTabs
        v-model="tab"
        :tabs="tabs"
        :orientation="orientation"
        :activation-mode="manual ? 'manual' : 'automatic'"
        id-base="activation-demo"
      />

      <GrTabPanels v-model="tab" id-base="activation-demo" class="min-w-[16rem] flex-1">
        <GrTabPanel v-for="item in tabs" :key="item.value" :value="item.value">
          Панель «{{ item.label }}»
        </GrTabPanel>
      </GrTabPanels>
    </div>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      В ручном режиме стрелки двигают только фокус — выбор подтверждает `Enter` или `Space`.
      Отключённая вкладка остаётся объявленной, но пропускается при переборе.
    </div>
  </div>
</template>

Базовое переключение с управляемым состоянием

Базовый controlled-pattern: GrTabs хранит только выбранное значение, а содержимое панели принадлежит странице.

Overview tab is the default landing surface for a compact summary.
Active tab: overview

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

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

const currentTab = ref('overview')
const tabs = [
  { value: 'overview', label: 'Overview' },
  { value: 'activity', label: 'Activity' },
  { value: 'billing', label: 'Billing' },
]

const panelContent = computed(() => {
  if (currentTab.value === 'activity')
    return 'Activity tab usually hosts timelines, audits and operator actions.'

  if (currentTab.value === 'billing')
    return 'Billing tab is a natural place for invoices, payment status and limits.'

  return 'Overview tab is the default landing surface for a compact summary.'
})
</script>

<template>
  <div class="grid gap-3">
    <GrTabs v-model="currentTab" :tabs="tabs" />

    <div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-fg)] shadow-[var(--gr-shadow-1)]">
      {{ panelContent }}
    </div>

    <GrBadge>
      Active tab: {{ currentTab }}
    </GrBadge>
  </div>
</template>

Закрываемые вкладки и пустой ряд

closable вешает крестик на вкладки и включает закрытие по Delete/Backspace; closable: false у отдельной вкладки закрепляет её. Крестик — намеренно не кнопка: вложенный интерактив внутри role="tab" теряется у скринридера, поэтому клик по нему разбирает сама вкладка, а про клавишу сообщает aria-keyshortcuts. Закройте все вкладки — на месте ряда появится текст пустого состояния.

README.md is pinned (closable: false) and stays put — "Close all" empties the list to show the empty row. Click the close icon, or focus a tab and press Delete.

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

import { GrButton, GrTabs, type GrTab } from '@feugene/granularity'

const initial: GrTab[] = [
  { value: 'readme', label: 'README.md', icon: 'i-lucide-pin', closable: false },
  { value: 'index', label: 'index.ts' },
  { value: 'styles', label: 'styles.css' },
  { value: 'config', label: 'vite.config.ts' },
]

const tabs = ref<GrTab[]>([...initial])
const currentTab = ref('index')

/**
 * Компонент эмитит только `close`: список — проп, и закрытие может не
 * состояться («сохранить изменения?»). Выбор соседа тоже за потребителем.
 */
function close(value: string) {
  const index = tabs.value.findIndex(tab => tab.value === value)
  if (index < 0)
    return

  tabs.value.splice(index, 1)

  if (currentTab.value === value)
    currentTab.value = tabs.value[index]?.value ?? tabs.value[index - 1]?.value ?? ''
}

function closeAll() {
  tabs.value = []
  currentTab.value = ''
}

function restore() {
  tabs.value = [...initial]
  currentTab.value = 'index'
}
</script>

<template>
  <div class="grid gap-3">
    <GrTabs
      v-model="currentTab"
      :tabs="tabs"
      closable
      variant="line"
      empty-text="No open files"
      @close="close"
    />

    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" variant="secondary" :disabled="tabs.length === initial.length" @click="restore">
        Reopen all
      </GrButton>

      <GrButton size="sm" variant="ghost-border" :disabled="tabs.length === 0" @click="closeAll">
        Close all
      </GrButton>

      <span class="text-sm text-[var(--gr-muted-fg)]">
        README.md is pinned (<code>closable: false</code>) and stays put — "Close all" empties the list to show the
        empty row. Click the close icon, or focus a tab and press <kbd>Delete</kbd>.
      </span>
    </div>
  </div>
</template>

Компонент эмитит только close(value) — список tabs остаётся у потребителя: закрытие может не состояться («сохранить изменения?»), и переключать вкладку заранее нельзя. Фокус после закрытия компонент возвращает сам, как только список действительно укоротился.

Вкладки со счётчиками для очередей

Показываем badge не как украшение, а как часть операционного UI — очереди, ревью, blocked items и другие counters.

Queue: 12Reviews: 4Blocked: 2

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

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

const currentTab = ref('queue')
const tabs = [
  { value: 'queue', label: 'Queue', badge: '12', icon: 'i-lucide-inbox' },
  { value: 'reviews', label: 'Reviews', badge: '4', icon: 'i-lucide-eye' },
  { value: 'blocked', label: 'Blocked', badge: '2', icon: 'i-lucide-ban' },
]
</script>

<template>
  <div class="grid gap-3">
    <GrTabs v-model="currentTab" :tabs="tabs" />

    <GrTabs v-model="currentTab" :tabs="tabs" variant="line" />

    <div class="flex flex-wrap gap-2">
      <GrBadge v-for="tab in tabs" :key="tab.value" :tone="tab.value === currentTab ? 'primary' : 'neutral'">
        {{ tab.label }}: {{ tab.badge }}
      </GrBadge>
    </div>
  </div>
</template>

Вкладки как переключатель панелей страницы

Документируем ключевую идею: GrTabs — это navigation primitive, а не готовая система вкладочных панелей с собственной разметкой.

Service health summary
Tabs stay presentation-focused while the page decides which panel to render.
Uptime 99.96%

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

import { GrBadge, GrButton, GrTabs } from '@feugene/granularity'

type ViewKey = 'summary' | 'incidents' | 'contacts'

const currentTab = ref<ViewKey>('summary')
const tabs = [
  { value: 'summary', label: 'Summary' },
  { value: 'incidents', label: 'Incidents', badge: '3' },
  { value: 'contacts', label: 'Contacts' },
] satisfies Array<{ value: ViewKey, label: string, badge?: string }>

const sectionTitle = computed(() => {
  if (currentTab.value === 'incidents')
    return 'Escalation queue'

  if (currentTab.value === 'contacts')
    return 'On-call contacts'

  return 'Service health summary'
})
</script>

<template>
  <div class="grid gap-4">
    <GrTabs v-model="currentTab" :tabs="tabs" />

    <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 shadow-[var(--gr-shadow-1)]">
      <div class="flex flex-wrap items-center justify-between gap-3">
        <div>
          <div class="text-sm font-600 text-[var(--gr-fg)]">
            {{ sectionTitle }}
          </div>
          <div class="text-sm text-[var(--gr-muted-fg)]">
            Tabs stay presentation-focused while the page decides which panel to render.
          </div>
        </div>

        <GrButton size="sm" variant="outline">
          Refresh panel
        </GrButton>
      </div>

      <div class="flex flex-wrap gap-2">
        <GrBadge v-if="currentTab === 'summary'">
          Uptime 99.96%
        </GrBadge>
        <GrBadge v-else-if="currentTab === 'incidents'" tone="warning">
          3 incidents require follow-up
        </GrBadge>
        <GrBadge v-else>
          5 contacts in rotation
        </GrBadge>
      </div>
    </div>
  </div>
</template>

Этот сценарий помогает не ожидать от компонента скрытой магии с panels/portals: orchestration остаётся снаружи.

Шкала размеров

Высота вкладки повторяет шкалу GrButton — вкладки часто стоят с кнопкой в один ряд. Счётчик у вкладки масштабируется вместе с подписью.

size="xs"
size="sm"
size="md"
size="lg"

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

import { GrTabs } from '@feugene/granularity'

const sizes = ['xs', 'sm', 'md', 'lg'] as const

const active = ref('overview')

const tabs = [
  { value: 'overview', label: 'Overview' },
  { value: 'activity', label: 'Activity', badge: '12' },
  { value: 'settings', label: 'Settings' },
]
</script>

<template>
  <div class="grid gap-4">
    <div v-for="size in sizes" :key="size" class="grid gap-2">
      <div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
        size="{{ size }}"
      </div>

      <GrTabs v-model="active" :tabs="tabs" :size="size" />
    </div>
  </div>
</template>

Ряд не влезает

Полоса прокрутки скрыта, поэтому продолжение выдаёт затухание у того края, за которым ещё есть вкладки. Маской, а не градиентом: фон полосы у line компоненту неизвестен.

Ряд гаснет у того края, за которым есть продолжение: в начале — справа, в конце — слева, в середине — с обеих сторон. Прокрутите ряд и проследите, как затухание переезжает. Влезает целиком — не гаснет вовсе.

Полоса прокрутки у ряда скрыта намеренно: под вкладками она выглядит чужеродно, а на macOS система прячет её до начала прокрутки — то есть показала бы продолжение уже после того, как пользователь о нём догадался.

Вкладки за краем достижимы и без мыши: стрелки ведут по ряду, а активная вкладка сама подтягивается в видимую часть — в том числе когда её выбрали снаружи. Отступ прокрутки равен ширине затухания, поэтому кольцо фокуса не оказывается под ним. Ширина — хук --gr-tabs-scroll-fade.

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

import { GrSegmented, GrTabs } from '@feugene/granularity'

/**
 * Ширина контейнера, а не окна: переполнение считается по доступному месту,
 * поэтому увидеть его можно не трогая размер браузера.
 *
 * Вариант переключается рядом намеренно. Полоса `pills` несёт свой непрозрачный
 * фон, `line` прозрачна и лежит на фоне страницы — затухание обязано читаться на
 * обоих, и именно поэтому оно сделано маской, а не градиентом-подложкой:
 * градиенту было бы неоткуда взять цвет подложки для `line`.
 */
const width = ref('260')
const widths = [
  { value: '260', label: '260px' },
  { value: '375', label: '375px' },
  { value: '520', label: '520px' },
]

const variant = ref<'pills' | 'line'>('pills')
const variants = [
  { value: 'pills', label: 'pills' },
  { value: 'line', label: 'line' },
]

const active = ref('overview')
const tabs = [
  { value: 'overview', label: 'Обзор' },
  { value: 'security', label: 'Безопасность' },
  { value: 'notifications', label: 'Уведомления' },
  { value: 'plan', label: 'Тариф' },
  { value: 'account', label: 'Аккаунт' },
  { value: 'sessions', label: 'Сеансы' },
  { value: 'api', label: 'Ключи API' },
]
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-4">
      <label class="grid gap-1 text-[length:var(--gr-control-text-sm)]">
        <span class="showcase-demo-text">Ширина контейнера</span>
        <GrSegmented v-model="width" :options="widths" size="sm" />
      </label>

      <label class="grid gap-1 text-[length:var(--gr-control-text-sm)]">
        <span class="showcase-demo-text">Вид ряда</span>
        <GrSegmented v-model="variant" :options="variants" size="sm" />
      </label>
    </div>

    <div
      data-demo-tabs-box
      class="rounded-[var(--gr-radius-md)] border border-dashed border-[var(--gr-brd)] p-3"
      :style="{ width: `${width}px`, maxWidth: '100%' }"
    >
      <GrTabs v-model="active" :tabs="tabs" :variant="variant" size="sm" />
    </div>

    <p class="showcase-demo-text text-sm">
      Ряд гаснет <b>у того края, за которым есть продолжение</b>: в начале — справа, в конце —
      слева, в середине — с обеих сторон. Прокрутите ряд и проследите, как затухание переезжает.
      Влезает целиком — не гаснет вовсе.

      <br><br>

      Полоса прокрутки у ряда скрыта намеренно: под вкладками она выглядит чужеродно, а на macOS
      система прячет её до начала прокрутки — то есть показала бы продолжение уже после того, как
      пользователь о нём догадался.

      <br><br>

      Вкладки за краем <b>достижимы и без мыши</b>: стрелки ведут по ряду, а активная вкладка сама
      подтягивается в видимую часть — в том числе когда её выбрали снаружи. Отступ прокрутки равен
      ширине затухания, поэтому кольцо фокуса не оказывается под ним. Ширина — хук
      <code>--gr-tabs-scroll-fade</code>.
    </p>
  </div>
</template>

Доступность

Паттерн APG
tabs
Клавиши
/ (при orientation="vertical"/) — циклично по вкладкам, Home/End — к первой/последней; при activationMode="manual" стрелка двигает только фокус, выбор подтверждает Enter/Space. Отключённые вкладки пропускаются, но остаются достижимыми. Delete/Backspace — закрыть вкладку под фокусом, если она закрываемая (крестик в таб-порядке не стоит: внутри role="tab" вложенная кнопка запрещена)

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

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