GrSegmented
Берут, когда переключение вида одного и того же.
Когда брать
- переключение вида одного и того же — список или плитка, день или месяц, график или таблица;
- вариантов 2–5 и все видны — выбор без раскрытия панели и без лишнего клика;
- вариант — иконка — режимы отображения узнаются значком быстрее, чем словом;
- значение уходит в форму — скрытое поле с
nameподключает сегменты к нативной отправке.
Когда взять другое
| Нужно | Берите |
|---|---|
| Вариантов больше пяти | GrSelect |
| Это значение поля, а не режим | GrRadioGroup |
| Разделы с разным содержимым | GrTabs |
| Вариантов два и это «включено/выключено» | GrSwitch |
| Действия, а не выбор | GrButtonGroup |
Сегменты против вкладок: сегменты меняют вид одного содержимого, вкладки переключают разное содержимое. Спутать их легко, а пользователю разница видна сразу — от неё зависит, ждать ли смены всей области.
Опции и слот
<GrSegmented v-model="view" :options="options" aria-label="Вид списка" />
Опция — { value, label?, icon?, disabled?, loading?, ariaLabel? }. Иконка
декоративна (aria-hidden), поэтому у icon-only сегмента обязателен
ariaLabel: иначе у него нет имени вовсе.
Дефолтный слот заменяет содержимое сегмента и получает
{ option, selected, disabled, loading }.
Занятый сегмент
{ value: 'review', label: 'Review', loading: syncing }
loading показывает спиннер на месте иконки и помечает сегмент aria-busy.
Выбор он не принимает, а стрелки его перешагивают — как и disabled.
Разница с disabled смысловая, и она видна скринридеру: занятый сегмент
не получает aria-disabled и нативный disabled — он доступен, просто
сейчас работает. Недоступный получает и то, и другое.
Только чтение и недоступность
readonly показывает выбор, но не даёт его менять — ни кликом, ни клавиатурой;
группа объявляется aria-readonly. disabled гасит весь контрол.
Оба состояния гасятся токеном --gr-disabled-fg, а не прозрачностью: opacity
разбавляет выверенные на AA цвета текста.
Нативная форма
Значение уходит одним скрытым полем рядом с сегментами (name), а не
вложенным в каждый role="radio": роль объявляет своих потомков
презентационными, и вложенный интерактивный контрол ломает виджет для
скринридеров. Недоступный выбранный сегмент значение не отправляет.
Оформление
| Проп | Что делает |
|---|---|
variant | pills (по умолчанию) или button — тень у дорожки и индикатора |
size | xs…lg, читается из GrConfigProvider |
orientation | horizontal (по умолчанию) или vertical |
block | сегменты растягиваются на всю ширину контейнера |
indicatorDuration | длительность анимации индикатора, мс |
Точечная кастомизация — через --gr-segmented-* (радиус, отступы, цвета
дорожки и индикатора, кегль).
Длинная подпись обрезается, но не пропадает
Подпись сегмента не переносится: ряд обязан оставаться рядом. Не поместившийся хвост прячется многоточием, и «Включено в подписку» превращается в «Включе…».
Полный текст при этом никуда не девается: truncate — правило отрисовки, в DOM
строка остаётся целой, и скринридер читает её целиком. Теряет её ровно один
читатель — тот, кто смотрит глазами, и ему подпись отдаётся нативной подсказкой
при наведении. Подсказка появляется только когда текст действительно обрезан:
тултип, дублирующий видимую целиком подпись, — шум, которого никто не просил.
Тот же обработчик доступен снаружи — titleWhenTruncated из пакета, для своей
разметки с truncate.
Вертикальный ряд
<GrSegmented v-model="scope" :options="filters" orientation="vertical" block />
Типовой сценарий — боковые фильтры. Ряд разворачивается в колонку, корень
объявляет aria-orientation.
Индикатору для этого ничего не понадобилось: он измеряется в двух измерениях
(translate3d плюс width/height) и едет вниз ровно так же, как вдоль ряда.
Смена ориентации на лету пересчитывает геометрию — иначе индикатор остался бы в
координатах прежней раскладки.
В вертикали колонка одна, поэтому сегменты одинаковой ширины по построению, а
block решает только, занимать ли ширину контейнера.
Радиус дорожки в вертикали считается от высоты сегмента, а не берётся пилюлей:
9999px выверен под короткий ряд и на высокой колонке превращал бы дорожку в
эллипс. Сегменты внутри при этом остаются пилюлями — они считают свой радиус от
того же значения.
Клавиатура
←/↑ и →/↓ двигают выбор с переносом через край, Home/End — к первому
и последнему доступному сегменту. Недоступные и занятые пропускаются.
Обе оси работают в любой ориентации — так требует APG для radiogroup:
вертикальный ряд не отключает горизонтальные стрелки и наоборот.
Playground 10
Загружается…
<GrSegmented />Установка
npm i @feugene/granularityИмпорт
import { GrSegmented } from '@feugene/granularity/components/GrSegmented'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
variant | GrSegmentedVariant | undefined | undefined | — |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Только для чтения: выбор видно, но он не меняется. |
invalid | boolean | undefined | false | Визуальное и ARIA-состояние ошибки. |
required | boolean | undefined | false | Обязательное поле (`aria-required`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
name | string | undefined | undefined | Имя скрытого поля, которым выбранное значение уходит в нативную форму. |
block | boolean | undefined | false | Растягивать сегмент на всю ширину контейнера. |
orientation | "horizontal" | "vertical" | undefined | "horizontal" | Направление ряда. Вертикаль — боковые фильтры; индикатор к ней готов по построению, он двумерный. |
indicatorDuration | number | undefined | 300 | Длительность анимации индикатора в мс. |
modelValueобязательный | GrSegmentedValue | — | — |
optionsобязательный | GrSegmentedOption[] | — | — |
Slots
| Slot | Type | Описание |
|---|---|---|
default | { option: GrSegmentedOption; selected: boolean; disabled: boolean; loading: boolean; } | Содержимое сегмента вместо подписи из `options`. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: GrSegmentedValue] | — |
change | [value: GrSegmentedValue, option: GrSegmentedOption] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Примеры 5
Вариант «таблетки» для компактного переключения
Базовый happy-path для GrSegmented: лёгкий pills-control с moving indicator и выбором одного значения.
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrSegmentedOption } from '@feugene/granularity'
import { GrBadge, GrSegmented } from '@feugene/granularity'
const period = ref<'day' | 'week' | 'month'>('week')
const options: GrSegmentedOption[] = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]
const selectionLabel = computed(() => options.find(option => option.value === period.value)?.label ?? period.value)
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div class="grid gap-4 rounded-[24px] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Revenue snapshot
</div>
<div class="mt-1 text-sm text-[var(--gr-muted-fg)]">
A switcher with a soft pills backing and an animated selected track.
</div>
</div>
<GrBadge tone="success" size="sm">
+12.4%
</GrBadge>
</div>
<GrSegmented v-model="period" :options="options" :indicator-duration="360" aria-label="Period" />
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
Active segment:
<div class="mt-2 text-base font-semibold text-[var(--gr-fg)]">
{{ selectionLabel }}
</div>
</div>
</div>
</template>Кнопочный вариант и смена размера на лету
Button-like режим подходит для toolbar и view-switcher сценариев, но сохраняет общий segmented UX и анимацию индикатора.
<script setup lang="ts">
import { ref } from 'vue'
import type { GrSegmentedOption, GrSelectOption, GrSegmentedSize } from '@feugene/granularity'
import { GrFormField, GrSegmented, GrSelect } from '@feugene/granularity'
const view = ref<'board' | 'calendar' | 'table'>('board')
const size = ref<GrSegmentedSize>('md')
const indicatorDuration = ref('400')
const sizeOptions: GrSelectOption[] = [
{ value: 'xs', label: 'Extra small' },
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Medium' },
{ value: 'lg', label: 'Large' },
]
const durationOptions: GrSelectOption[] = [
{ value: '200', label: 'Fast · 200 ms' },
{ value: '400', label: 'Balanced · 400 ms' },
{ value: '800', label: 'Smooth · 800 ms' },
]
const viewOptions: GrSegmentedOption[] = [
{ value: 'board', label: 'Board' },
{ value: 'calendar', label: 'Calendar' },
{ value: 'table', label: 'Table' },
]
</script>
<template>
<div class="grid gap-4">
<!-- Подписи через GrFormField, а не отдельным div: он выдаёт контролу id и
связывает с ним `<label for>`. Нарисованный рядом текст доступным именем
не становится — селект остаётся безымянным для скринридера. -->
<div class="grid gap-4 md:grid-cols-2 md:max-w-[520px]">
<GrFormField label="Segmented size">
<GrSelect v-model="size" :options="sizeOptions" />
</GrFormField>
<GrFormField label="Indicator speed">
<GrSelect v-model="indicatorDuration" :options="durationOptions" />
</GrFormField>
</div>
<GrSegmented
v-model="view"
:options="viewOptions"
variant="button"
:size="size"
:indicator-duration="Number(indicatorDuration)"
aria-label="View"
/>
</div>
</template>Иконка с подписью и только иконка
Компонент умеет работать и с icon + label, и с компактным icon-only рендерингом через scoped slot без раздувания API.
<script setup lang="ts">
import { ref } from 'vue'
import type { GrSegmentedOption } from '@feugene/granularity'
import { GrSegmented } from '@feugene/granularity'
import IconCalendarDays from '~icons/lucide/calendar-days'
import IconLayoutGrid from '~icons/lucide/layout-grid'
import IconRows3 from '~icons/lucide/rows-3'
import IconSunMoon from '~icons/lucide/sun-moon'
const dashboardView = ref<'board' | 'timeline' | 'calendar'>('board')
const iconOnlyView = ref<'board' | 'timeline' | 'calendar'>('timeline')
const dashboardOptions: GrSegmentedOption[] = [
{ value: 'board', label: 'Board', icon: IconLayoutGrid },
{ value: 'timeline', label: 'Timeline', icon: IconRows3 },
{ value: 'calendar', label: 'Calendar', icon: IconCalendarDays },
]
// Icon-only: иконка декоративна, поэтому имя сегмента задаётся явно —
// иначе скринридер объявит три пустые кнопки.
const iconOnlyOptions: GrSegmentedOption[] = [
{ value: 'board', icon: IconLayoutGrid, ariaLabel: 'Board' },
{ value: 'timeline', icon: IconRows3, ariaLabel: 'Timeline' },
{ value: 'calendar', icon: IconCalendarDays, ariaLabel: 'Calendar' },
]
</script>
<template>
<div class="grid gap-5 lg:grid-cols-2">
<div class="grid gap-3 rounded-[24px] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Icon + label
</div>
<GrSegmented
v-model="dashboardView"
:options="dashboardOptions"
:indicator-duration="260"
aria-label="Dashboard view"
/>
</div>
<div class="grid gap-3 rounded-[24px] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Icon-only with scoped slot
</div>
<GrSegmented
v-model="iconOnlyView"
:options="iconOnlyOptions"
size="sm"
:indicator-duration="420"
aria-label="Compact view switcher"
>
<template #default="{ option, selected }">
<component :is="option.icon ?? IconSunMoon" class="h-4 w-4" :class="selected ? '' : 'opacity-70'" />
</template>
</GrSegmented>
</div>
</div>
</template>Вертикальный ряд для боковых фильтров
orientation="vertical" разворачивает ряд в колонку и объявляет aria-orientation. Индикатор к этому готов по построению: он измеряется в двух измерениях и едет вниз ровно так же, как вдоль ряда. Переключатель сверху меняет ориентацию на лету — видно, что индикатор пересчитывается, а не остаётся в координатах прежней раскладки.
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrSegmentedOption, GrSegmentedOrientation } from '@feugene/granularity'
import { GrSegmented } from '@feugene/granularity'
const scope = ref('all')
const orientation = ref<GrSegmentedOrientation>('vertical')
const filters: GrSegmentedOption[] = [
{ value: 'all', label: 'All issues' },
{ value: 'mine', label: 'Assigned to me' },
{ value: 'review', label: 'In review' },
{ value: 'archived', label: 'Archived', disabled: true },
]
const orientations: GrSegmentedOption[] = [
{ value: 'vertical', label: 'Vertical' },
{ value: 'horizontal', label: 'Horizontal' },
]
const activeLabel = computed(() => filters.find(f => f.value === scope.value)?.label ?? scope.value)
</script>
<template>
<div class="grid gap-4">
<GrSegmented
v-model="orientation"
:options="orientations"
size="sm"
aria-label="Orientation"
/>
<div
class="grid gap-4 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4"
:class="orientation === 'vertical' ? 'md:grid-cols-[220px_minmax(0,1fr)]' : ''"
>
<GrSegmented
v-model="scope"
:options="filters"
:orientation="orientation"
:block="orientation === 'vertical'"
aria-label="Issue filter"
/>
<div class="text-sm text-[var(--gr-muted-fg)]">
Sidebar filters are the reason vertical exists. The indicator needed nothing new — it is measured in two
dimensions, so it slides down the column exactly as it slides across the row.
<div class="mt-2 text-base font-semibold text-[var(--gr-fg)]">
{{ activeLabel }}
</div>
</div>
</div>
</div>
</template>Клавиатура не меняется: у radiogroup обе оси стрелок работают в любой ориентации, как требует APG. В вертикали колонка одна, поэтому сегменты одинаковой ширины по построению, а block решает лишь, занимать ли ширину контейнера.
Выключенные пункты, во всю ширину и переключатель языка
Собираем реальные product-like сценарии: language pills, full-width layout и disabled item внутри группы без потери читаемости.
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrSegmentedOption } from '@feugene/granularity'
import { GrButton, GrSegmented } from '@feugene/granularity'
const locale = ref<'ru' | 'en'>('ru')
const status = ref<'draft' | 'review' | 'published'>('review')
const localeOptions: GrSegmentedOption[] = [
{ value: 'ru', label: 'RU' },
{ value: 'en', label: 'EN' },
]
// `syncing` — сегмент занят: спиннер вместо иконки, выбор не принимается,
// стрелки его перешагивают.
const syncing = ref(false)
const statusOptions = computed<GrSegmentedOption[]>(() => [
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review', loading: syncing.value },
{ value: 'published', label: 'Published', disabled: true },
])
function syncReview() {
syncing.value = true
window.setTimeout(() => {
syncing.value = false
}, 2000)
}
const statusLabel = computed(() => statusOptions.value.find(option => option.value === status.value)?.label ?? status.value)
</script>
<template>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_240px]">
<div class="grid gap-4 rounded-[24px] border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<div class="grid gap-3">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Language switcher
</div>
<GrSegmented v-model="locale" :options="localeOptions" size="sm" :indicator-duration="220" aria-label="Language" />
</div>
<div class="grid gap-3">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Block layout + disabled item
</div>
<GrSegmented
v-model="status"
:options="statusOptions"
block
variant="button"
:indicator-duration="500"
aria-label="Publishing status"
/>
</div>
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
Selected state:
<div class="mt-2 text-base font-semibold text-[var(--gr-fg)]">
{{ statusLabel }}
</div>
<div class="mt-3 text-sm">
The disabled option stays visible and keeps the structure of the choice set.
</div>
<GrButton class="mt-3" size="sm" variant="outline" :disabled="syncing" @click="syncReview">
Sync «Review» for 2s
</GrButton>
</div>
</div>
</template>Доступность
- Паттерн APG
radiogroup- Клавиши
←/→/↑/↓— по сегментам (обе оси работают в любойorientation, как требует APG дляradiogroup),Home/End— к краям,Space/Enter— выбрать