GrTabs
Берут, когда разделы с разным содержимым.
Обзор
Когда брать
- разделы с разным содержимым — профиль, безопасность, уведомления: одновременно нужен один;
- у вкладки есть счётчик или иконка —
badgeиiconбез своей разметки; - вкладки закрываются —
closableдля рабочих областей и открытых документов; - вкладок много — они уезжают в прокрутку, а не ломают раскладку.
Когда взять другое
| Нужно | Берите |
|---|---|
| Переключается вид одного и того же | GrSegmented |
| Разделы видны все сразу | GrCollapse |
| Разделы формы идут подряд | GrFormSection |
| Переход ведёт на другой экран | GrLink / GrSidebar |
| Нужны сами панели | GrTabPanels |
Варианты все тона и виды — настоящим компонентом
variant
pillsline
Размеры
xssmmdlg
Матрица живая: наводите и нажимайте. Состояний наведения, фокуса и нажатия здесь нет — они за браузером, а не за пропом.
Playground контролы выводятся из API, а не пишутся руками
Загружается…
0 изменено· состояние — в адресе
<GrTabs :tabs="tabs" v-model="value" />Примеры 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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
variant | "pills" | "line" | undefined | undefined | Вид ряда: обойма с таблетками или ряд с подчёркиванием. |
closable | boolean | undefined | false | Крестик на вкладках и закрытие по `Delete`/`Backspace`. Точечно снимается `closable: false` у самой вкладки. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Высота вкладок и кегль подписи по шкале контролов. По умолчанию `md`. |
orientation | GrTabsOrientation | undefined | "horizontal" | Горизонтальный (по умолчанию) или вертикальный список вкладок. |
activationMode | GrTabsActivationMode | undefined | "automatic" | `automatic` (по умолчанию) — стрелка сразу переключает вкладку; `manual` — стрелка двигает только фокус, выбор подтверждается `Enter`/`Space`. Второй режим для вкладок с тяжёлой загрузкой: перебор стрелками иначе тянет каждую панель. |
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`. |
modelValueобязательныйv-model | string | — | `value` выбранной вкладки (`v-model`). Не совпал ни с одной — `Tab` встаёт на первую доступную. |
tabsобязательный | GrTab[] | — | Вкладки в порядке вывода: `value`, `label`, по желанию иконка, счётчик и `disabled`. |
Слоты
| Slot | Type | Описание |
|---|---|---|
tab | { tab: GrTab; active: boolean; disabled: boolean; } | Содержимое вкладки целиком — вместо подписи, иконки и счётчика. Крестик остаётся. |
empty | any | Пустой ряд — вместо текста из локали. |
События
| 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
interface GrTab {
value: string;
label: string;
badge?: string;
/**
* Иконка слева от подписи: Vue-компонент либо класс иконки (`'i-lucide-user'` —
* правило для класса заводит приложение: фабрике движка или провайдером,
* см. `docs/installation.md#иконки`).
*/
icon?: string | Component;
disabled?: boolean;
/** Перекрывает `closable` ряда в обе стороны: закрепить вкладку или закрыть одну её. */
closable?: boolean;
}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;
}interface GrTabsEmits {
/** Выбрана другая вкладка: кликом, стрелкой или `Enter` в режиме `manual`. */
(e: 'update:modelValue', value: string): void;
/**
* Просьба закрыть вкладку. Компонент список не трогает: `tabs` — проп, и
* закрытие может не состояться («сохранить изменения?»).
*/
(e: 'close', value: string): void;
}Файлdist/types/components/GrTabs/config.d.ts
const grTabsConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrTabs">;Файлdist/types/components/GrTabs/defaults.d.ts
Пропы GrTabs, настраиваемые глобально через componentDefaults.
interface GrTabsConfigurableProps {
size: GrComponentSize;
variant: GrTabsVariant;
}Файлdist/types/components/GrTabs/grTabsStyles.d.ts
type GrTabsSize = GrComponentSize;type GrTabsVariant = typeof GR_TABS_VARIANTS[number];type GrTabsOrientation = 'horizontal' | 'vertical';Файлdist/types/components/GrTabs/safelist.d.ts
const grTabsSafelist: string[];Доступность
Паттерн APG
tabs
Клавиши
Home / End
| Клавиша | Действие |
|---|---|
| — | ←/→ (при orientation="vertical" — ↑/↓) — циклично по вкладкам |
| Home / End | к первой/последней; при activationMode="manual" стрелка двигает только фокус, выбор подтверждает Enter/Space. Отключённые вкладки пропускаются, но остаются достижимыми. Delete/Backspace — закрыть вкладку под фокусом, если она закрываемая (крестик в таб-порядке не стоит: внутри role="tab" вложенная кнопка запрещена) |
Полный контракт пакета: docs/keyboard.md ↗
Использование из прозы библиотеки
Вид ряда
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 }:
<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" на закрываемых
вкладках. Закрывается именно та, на которой стоит фокус, а не выбранная: при
activation-mode="manual" они законно расходятся.
Список остаётся у потребителя. Компонент эмитит только 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>.
Пропы этого примера