GrSegmented

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда переключение вида одного и того же.

Когда брать

  • переключение вида одного и того же — список или плитка, день или месяц, график или таблица;
  • вариантов 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": роль объявляет своих потомков презентационными, и вложенный интерактивный контрол ломает виджет для скринридеров. Недоступный выбранный сегмент значение не отправляет.

Оформление

ПропЧто делает
variantpills (по умолчанию) или button — тень у дорожки и индикатора
sizexslg, читается из GrConfigProvider
orientationhorizontal (по умолчанию) или 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

PropTypeпо умолчаниюОписание
variantGrSegmentedVariant | undefinedundefined
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: выбор видно, но он не меняется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
namestring | undefinedundefinedИмя скрытого поля, которым выбранное значение уходит в нативную форму.
blockboolean | undefinedfalseРастягивать сегмент на всю ширину контейнера.
orientation"horizontal" | "vertical" | undefined"horizontal"Направление ряда. Вертикаль — боковые фильтры; индикатор к ней готов по построению, он двумерный.
indicatorDurationnumber | undefined300Длительность анимации индикатора в мс.
modelValueобязательныйGrSegmentedValue
optionsобязательныйGrSegmentedOption[]

Slots

SlotTypeОписание
default{ option: GrSegmentedOption; selected: boolean; disabled: boolean; loading: boolean; }Содержимое сегмента вместо подписи из `options`.

Events

EventTypeОписание
update:modelValue[value: GrSegmentedValue]
change[value: GrSegmentedValue, option: GrSegmentedOption]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeОписание
focus() => void
blur() => void

Примеры 5

Вариант «таблетки» для компактного переключения

Базовый happy-path для GrSegmented: лёгкий pills-control с moving indicator и выбором одного значения.

Revenue snapshot
A switcher with a soft pills backing and an animated selected track.
+12.4%
Active segment:
Week

Basic
<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 и анимацию индикатора.

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

Contentзависит от окружения витрины
<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. Индикатор к этому готов по построению: он измеряется в двух измерениях и едет вниз ровно так же, как вдоль ряда. Переключатель сверху меняет ориентацию на лету — видно, что индикатор пересчитывается, а не остаётся в координатах прежней раскладки.

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.
All issues

Vertical
<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 внутри группы без потери читаемости.

Language switcher
Block layout + disabled item
Selected state:
Review
The disabled option stays visible and keeps the structure of the choice set.

States
<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 — выбрать

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

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