GrTabs

  • ядро
  • Навигация
  • API · 9 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

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

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

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

Варианты все тона и виды — настоящим компонентом

variant

  • pills
  • line

Размеры

  • xs
  • sm
  • md
  • lg

Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.

Playground контролы выводятся из API, а не пишутся руками

Сцена

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

0 изменено· состояние — в адресе

Примеры 2 у каждого — живое демо и его код

  • Отчёты финансов под заголовком страницы

    Каждая вкладка запускает отчёт, поэтому activation-mode="manual": стрелки только двигают фокус, вкладку выбирают Enter или пробел. variant="line" стоит под заголовком страницы. Иконки и счётчики берутся из объектов вкладок, вкладка вне тарифа — disabled. Компонент — только ряд: содержимое — своя область страницы, связанная с рядом через id-base.

    Invoices23 open invoices, €18,240 — 4 of them overdue
  • Открытые шаблоны писем

    closable ставит крестик на каждую вкладку и закрывает выбранную по Delete; закреплённый README отказывается через closable: false. Ряд только сообщает close — список остаётся нашим, потому что закрытие может и не состояться. Слот tab рисует имя файла и точку несохранённого, а крестик остаётся за рядом.

    <mj-text>Thanks, {{customer.name}} — invoice {{invoice.number}} is paid.</mj-text>

API порождается из пакета · разойтись не может

Props

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

Слоты

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

События

EventСигнатураОписание
close(event: "close", value: string): void—
update:modelValue(event: "update:modelValue", value: string): void—

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrTab, GrTabsProps, GrTabsEmits } from '@feugene/granularity/components/GrTabs'

Файлdist/types/components/GrTabs/GrTabs.vue.d.ts

GrTabinterface
interface GrTab {
    value: string;
    label: string;
    badge?: string;
    /**
     * Иконка слева от подписи: Vue-компонент либо класс иконки (`'i-lucide-user'` —
     * правило для класса заводит приложение: фабрике движка или провайдером,
     * см. `docs/installation.md#иконки`).
     */
    icon?: string | Component;
    disabled?: boolean;
    /** Перекрывает `closable` ряда в обе стороны: закрепить вкладку или закрыть одну её. */
    closable?: boolean;
}
GrTabsPropsinterface
interface GrTabsProps {
    /** `value` выбранной вкладки (`v-model`). Не совпал ни с одной — `Tab` встаёт на первую доступную. */
    modelValue: string;
    /** Вкладки в порядке вывода: `value`, `label`, по желанию иконка, счётчик и `disabled`. */
    tabs: GrTab[];
    /**
     * База id для ARIA-связки с `GrTabPanels`. Если задана, каждая вкладка
     * получает `id="<idBase>-tab-<value>"` и `aria-controls="<idBase>-panel-<value>"`.
     * Передайте тот же `idBase` в `GrTabPanels`, чтобы связать `tab`↔`tabpanel`.
     */
    idBase?: string;
    /** Высота вкладок и кегль подписи по шкале контролов. По умолчанию `md`. */
    size?: GrTabsSize;
    /** Вид ряда: обойма с таблетками или ряд с подчёркиванием. */
    variant?: GrTabsVariant;
    /**
     * `automatic` (по умолчанию) — стрелка сразу переключает вкладку;
     * `manual` — стрелка двигает только фокус, выбор подтверждается
     * `Enter`/`Space`. Второй режим для вкладок с тяжёлой загрузкой: перебор
     * стрелками иначе тянет каждую панель.
     */
    activationMode?: GrTabsActivationMode;
    /** Горизонтальный (по умолчанию) или вертикальный список вкладок. */
    orientation?: GrTabsOrientation;
    /**
     * Крестик на вкладках и закрытие по `Delete`/`Backspace`. Точечно снимается
     * `closable: false` у самой вкладки.
     */
    closable?: boolean;
    /** Текст, когда список вкладок пуст. Слот `#empty` сильнее. */
    emptyText?: string;
}
GrTabsEmitsinterface
interface GrTabsEmits {
    /** Выбрана другая вкладка: кликом, стрелкой или `Enter` в режиме `manual`. */
    (e: 'update:modelValue', value: string): void;
    /**
     * Просьба закрыть вкладку. Компонент список не трогает: `tabs` — проп, и
     * закрытие может не состояться («сохранить изменения?»).
     */
    (e: 'close', value: string): void;
}

Файлdist/types/components/GrTabs/config.d.ts

grTabsConfigconst
const grTabsConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTabs">;

Файлdist/types/components/GrTabs/defaults.d.ts

GrTabsConfigurablePropsinterface

Пропы GrTabs, настраиваемые глобально через componentDefaults.

interface GrTabsConfigurableProps {
    size: GrComponentSize;
    variant: GrTabsVariant;
}

Файлdist/types/components/GrTabs/grTabsStyles.d.ts

GrTabsSizetype
type GrTabsSize = GrComponentSize;
GrTabsVarianttype
type GrTabsVariant = typeof GR_TABS_VARIANTS[number];
GrTabsOrientationtype
type GrTabsOrientation = 'horizontal' | 'vertical';

Файлdist/types/components/GrTabs/safelist.d.ts

grTabsSafelistconst
const grTabsSafelist: string[];

Доступность

Паттерн APG

tabs

Клавиши

Home / End

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

Использование из прозы библиотеки

Вид ряда
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-компонентом либо классом иконки (i-lucide-* — правило для него заводит приложение: фабрике движка или провайдером, иначе — Vue-компонент; см. «Иконки»).

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

vue
<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>
Закрываемые вкладки
vue
<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" на закрываемых вкладках. Закрывается именно та, на которой стоит фокус, а не выбранная: при activation-mode="manual" они законно расходятся.

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

ts
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, а не пустое состояние. Блок держит высоту вкладки, чтобы соседние блоки не прыгали, когда закрыли последнюю.

vue
<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.

Режим активации
vue
<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>.