GrTabs
Берут, когда разделы с разным содержимым.
Когда брать
- разделы с разным содержимым — профиль, безопасность, уведомления: одновременно нужен один;
- у вкладки есть счётчик или иконка —
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-controls ↔ aria-labelledby не сойдётся
(в dev-сборке панель об этом предупредит).
Почему фокус не теряется
Массив ссылок на кнопки чистится, когда Vue отдаёт null для исчезнувшего
узла, и обрезается по длине списка вкладок. Без этого в нём оставались
отсоединённые от DOM кнопки, и focus() по сократившемуся списку молча
проваливался в <body>.
Playground 6
Загружается…
<GrTabs />Установка
npm i @feugene/granularityИмпорт
import { GrTabs } from '@feugene/granularity/components/GrTabs'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
variant | "pills" | "line" | undefined | undefined | Вид ряда: обойма с таблетками или ряд с подчёркиванием. |
closable | boolean | undefined | false | Крестик на вкладках и закрытие по `Delete`/`Backspace`. Точечно снимается `closable: false` у самой вкладки. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
orientation | GrTabsOrientation | undefined | "horizontal" | Горизонтальный (по умолчанию) или вертикальный список вкладок. |
emptyText | string | undefined | undefined | Текст, когда список вкладок пуст. Слот `#empty` сильнее. |
idBase | string | undefined | undefined | База id для ARIA-связки с `GrTabPanels`. Если задана, каждая вкладка получает `id="<idBase>-tab-<value>"` и `aria-controls="<idBase>-panel-<value>"`. Передайте тот же `idBase` в `GrTabPanels`, чтобы связать `tab`↔`tabpanel`. |
activationMode | GrTabsActivationMode | undefined | "automatic" | `automatic` (по умолчанию) — стрелка сразу переключает вкладку; `manual` — стрелка двигает только фокус, выбор подтверждается `Enter`/`Space`. Второй режим для вкладок с тяжёлой загрузкой: перебор стрелками иначе тянет каждую панель. |
modelValueобязательный | string | — | — |
tabsобязательный | GrTab[] | — | — |
Slots
| Slot | Type | Описание |
|---|---|---|
tab | { tab: GrTab; active: boolean; disabled: boolean; } | Содержимое вкладки целиком — вместо подписи, иконки и счётчика. Крестик остаётся. |
empty | any | Пустой ряд — вместо текста из локали. |
Events
| Event | Type | Описание |
|---|---|---|
close | [value: string] | — |
update:modelValue | [value: string] | — |
Примеры 7
Режим активации и вертикальные вкладки
activationMode="manual" двигает стрелками только фокус, orientation="vertical" разворачивает список в колонку.
<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 хранит только выбранное значение, а содержимое панели принадлежит странице.
<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. Закройте все вкладки — на месте ряда появится текст пустого состояния.
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. <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.
<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, а не готовая система вкладочных панелей с собственной разметкой.
<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 — вкладки часто стоят с кнопкой в один ряд. Счётчик у вкладки масштабируется вместе с подписью.
<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.
<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"вложенная кнопка запрещена)