GrSegmented

  • ядро
  • Формы
  • API · 15 props
  • ⌨ клавиши

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

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

Обзор

Когда брать

  • переключение вида одного и того же — список или плитка, день или месяц, график или таблица;
  • вариантов 2–5 и все видны — выбор без раскрытия панели и без лишнего клика;
  • вариант — иконка — режимы отображения узнаются значком быстрее, чем словом;
  • значение уходит в форму — скрытое поле с name подключает сегменты к нативной отправке.

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

НужноБерите
Вариантов больше пятиGrSelect
Это значение поля, а не режимGrRadioGroup
Разделы с разным содержимымGrTabs
Вариантов два и это «включено/выключено»GrSwitch
Действия, а не выборGrButtonGroup

Сегменты против вкладок: сегменты меняют вид одного содержимого, вкладки переключают разное содержимое. Спутать их легко, а пользователю разница видна сразу — от неё зависит, ждать ли смены всей области.

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

variant

  • pills
  • button

Размеры

  • xs
  • sm
  • md
  • lg

Состояния

  • обычная
  • disabled
  • readonly
  • invalid

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

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

Сцена

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

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

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

  • Одна выручка — по периодам и в нужном виде

    Сегменты меняют вид одного и того же содержимого, а не само содержимое — этим они и отличаются от вкладок. Один ряд группирует выручку по неделям, месяцам или кварталам, второй рисует её графиком или таблицей: у сегментов-иконок свой ariaLabel, variant="button" поднимает индикатор над дорожкой, а change отдаёт опцию целиком — подпись над графиком называет её словами.

    RevenueBy month · Sep €204.6k, +16.1%
    €168kMay
    €172kJun
    €182kJul
    €176kAug
    €205kSep
  • Очередь ревью со счётчиками

    block занимает ширину панели, а item-width="content" делит её по подписям: равные трети обрезали бы «Needs my review», пока «All» держит место, которое ему не нужно. Слот добавляет к подписи счётчик. Пока CI не ответил, «Failing checks» — loading: его не выбрать, стрелки его перешагивают, но он доступен — aria-busy, а не aria-disabled.

    billing-service · pull requests
    • Retry failed webhooks with backoff#1842 · minaChecking…
    • Prorate seats added mid-cycle#1839 · danielChecking…
    • Dunning emails in the customer locale#1828 · graceChecking…
  • Период оплаты в обычной форме

    Рядом с полем ввода пилюля читается гостем из другой системы, поэтому shape="box" берёт скругление полей, а block — ширину колонки. name отправляет выбор одним скрытым полем нативной <form>. Годовой договор — от пяти мест: правило связывает два поля, и вердикт invalid ставит страница. Валюта зафиксирована первым счётом: readonly оставляет её на виду и в форме.

    Team planChanges apply from the next invoice

    €36 a month · €12 a seat

    Fixed after the first invoice

  • Очереди поддержки в боковой колонке

    orientation="vertical" собирает сегменты в колонку фильтров, а block отдаёт им ширину боковой панели. Архива в этом тарифе нет: недоступную опцию стрелки перешагивают. Пустая очередь предлагает выход — focus() ставит клавиатуру на выбранный сегмент, а не на первый, и дальше работают стрелки.

    Assigned to me0
    Nothing is assigned to youNew conversations land in «All open» first.

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

Props

PropTypeпо умолчаниюОписание
disabledboolean | undefinedfalseРяд недоступен: сегменты не выбираются и не фокусируются. Включается и от `GrFormField`.
variantGrSegmentedVariant | undefinedundefinedОформление: `button` добавляет тень дорожке и индикатору. По умолчанию `pills`.
readonlyboolean | undefinedfalseТолько для чтения: выбор видно, но он не меняется.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefinedВысота и кегль по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`.
ariaLabelstring | undefinedundefinedДоступное имя ряда. Внутри `GrFormField` не нужно: ряд именует подпись поля.
namestring | undefinedundefinedИмя скрытого поля, которым выбранное значение уходит в нативную форму.
shape"box" | "pill" | undefinedundefinedФорма рамки. `pill` — дорожка-пилюля, как была всегда; `box` — то же скругление, что у полей ввода, чтобы сегменты не выглядели в форме гостем из другой системы.
blockboolean | undefinedfalseРастягивать сегмент на всю ширину контейнера.
orientation"horizontal" | "vertical" | undefined"horizontal"Направление ряда. Вертикаль — боковые фильтры; индикатор к ней готов по построению, он двумерный.
itemWidth"equal" | "content" | undefinedundefinedКак делится ширина в `block`-ряду: `equal` — поровну, как было всегда; `content` — по содержимому, свободное место делится поверх него. `equal` остаётся умолчанием: с `content` ширина ряда зависела бы от длины подписей, и перевод интерфейса переставлял бы границы сегментов у всех. Без `block` и в вертикали проп не значит ничего — там ширина и так по содержимому, а колонка одна.
indicatorDurationnumber | undefined300Длительность анимации индикатора в мс.
modelValueобязательныйv-modelGrSegmentedValue—Выбранное значение (`v-model`). Стрелки переносят выбор вместе с фокусом.

Слоты

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

События

EventСигнатураОписание
update:modelValue(event: "update:modelValue", value: GrSegmentedValue): void—
change(event: "change", value: GrSegmentedValue, option: GrSegmentedOption): void—
focus(event: "focus", event: FocusEvent): void—
blur(event: "blur", event: FocusEvent): void—

Методы

MethodsСигнатураОписание
focus() => voidФокус на текущую остановку `Tab` ряда — обычно выбранный сегмент, а не первый.
blur() => voidСнять фокус с текущего сегмента ряда.

Типы

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

Импорт
import type { GrSegmentedConfigurableProps, GrSegmentedVariant, GrSegmentedSize } from '@feugene/granularity/components/GrSegmented'

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

grSegmentedConfigconst
const grSegmentedConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSegmented">;

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

GrSegmentedConfigurablePropsinterface

Пропы GrSegmented, настраиваемые глобально через <GrConfigProvider :component-defaults="{ GrSegmented: { … } }">. Только оформление — см. GrButton/defaults.ts.

interface GrSegmentedConfigurableProps {
    size: GrSegmentedSize;
    variant: GrSegmentedVariant;
    shape: GrControlShape;
    /** Как ряд делит ширину: поровну или по содержимому. */
    itemWidth: GrSegmentedItemWidth;
}

Файлdist/types/components/GrSegmented/grSegmentedStyles.d.ts

GrSegmentedVarianttype
type GrSegmentedVariant = 'pills' | 'button';
GrSegmentedSizetype
type GrSegmentedSize = GrComponentSize;
GrSegmentedOrientationtype
type GrSegmentedOrientation = typeof GR_SEGMENTED_ORIENTATIONS[number];
GrSegmentedItemWidthtype
type GrSegmentedItemWidth = typeof GR_SEGMENTED_ITEM_WIDTHS[number];
GrSegmentedValuetype
type GrSegmentedValue = string | number;
GrSegmentedOptiontype
type GrSegmentedOption = {
    value: GrSegmentedValue;
    label?: string;
    icon?: Component;
    disabled?: boolean;
    /**
     * Сегмент занят: вместо иконки крутится спиннер, выбор не принимается.
     * От `disabled` отличается смыслом — сегмент доступен, но сейчас работает.
     */
    loading?: boolean;
    /**
     * Доступное имя сегмента. Обязательно для icon-only опций: иконка декоративна
     * (`aria-hidden`), и без этого у сегмента нет имени вовсе — скринридер объявит
     * пустую кнопку. Когда есть `label`, имя берётся из него, и поле не нужно.
     */
    ariaLabel?: string;
};

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

GrSegmentedPropsinterface

Пропсы публичного GR-примитива «Segmented».

interface GrSegmentedProps {
    /** Выбранное значение (`v-model`). Стрелки переносят выбор вместе с фокусом. */
    modelValue: GrSegmentedValue;
    /** Сегменты: значение, подпись, иконка, `disabled`, `loading`. Сегменту-иконке нужен `ariaLabel`. */
    options: GrSegmentedOption[];
    /** Оформление: `button` добавляет тень дорожке и индикатору. По умолчанию `pills`. */
    variant?: GrSegmentedVariant;
    /** Высота и кегль по шкале контролов. По умолчанию `md`; берётся из `GrConfigProvider`. */
    size?: GrSegmentedSize;
    /**
     * Форма рамки. `pill` — дорожка-пилюля, как была всегда; `box` — то же
     * скругление, что у полей ввода, чтобы сегменты не выглядели в форме гостем
     * из другой системы.
     */
    shape?: GrControlShape;
    /**
     * Направление ряда. Вертикаль — боковые фильтры; индикатор к ней готов по
     * построению, он двумерный.
     */
    orientation?: GrSegmentedOrientation;
    /**
     * Как делится ширина в `block`-ряду: `equal` — поровну, как было всегда;
     * `content` — по содержимому, свободное место делится поверх него.
     *
     * `equal` остаётся умолчанием: с `content` ширина ряда зависела бы от длины
     * подписей, и перевод интерфейса переставлял бы границы сегментов у всех.
     * Без `block` и в вертикали проп не значит ничего — там ширина и так по
     * содержимому, а колонка одна.
     */
    itemWidth?: GrSegmentedItemWidth;
    /** Длительность анимации индикатора в мс. */
    indicatorDuration?: number;
    /** Растягивать сегмент на всю ширину контейнера. */
    block?: boolean;
    /** Ряд недоступен: сегменты не выбираются и не фокусируются. Включается и от `GrFormField`. */
    disabled?: boolean;
    /** Только для чтения: выбор видно, но он не меняется. */
    readonly?: boolean;
    /** Визуальное и ARIA-состояние ошибки. */
    invalid?: boolean;
    /** Обязательное поле (`aria-required`). */
    required?: boolean;
    /** Имя скрытого поля, которым выбранное значение уходит в нативную форму. */
    name?: string;
    /** Доступное имя ряда. Внутри `GrFormField` не нужно: ряд именует подпись поля. */
    ariaLabel?: string;
}
GrSegmentedEmitsinterface
interface GrSegmentedEmits {
    /** Новое значение (`v-model`): клик, стрелка, `Space` или `Enter` по другому сегменту. */
    (e: 'update:modelValue', value: GrSegmentedValue): void;
    /** Выбор сменился; вместе со значением приходит сама опция — с подписью и прочими полями. */
    (e: 'change', value: GrSegmentedValue, option: GrSegmentedOption): void;
    /** Фокус вошёл в ряд. Переходы между сегментами стрелками событием не считаются. */
    (e: 'focus', event: FocusEvent): void;
    /** Фокус ушёл из ряда целиком. */
    (e: 'blur', event: FocusEvent): void;
}

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

grSegmentedSafelistconst
const grSegmentedSafelist: string[];

Файлdist/types/components/GrSegmented/index.d.ts

GrSegmentedInstancetype
type GrSegmentedInstance = ComponentExposed<typeof GrSegmentedComponent>;

Доступность

Паттерн APG

radiogroup

Клавиши

← / → / ↑ / ↓Home / EndSpace / Enter

КлавишаДействие
← / → / ↑ / ↓по сегментам (обе оси работают в любой orientation, как требует APG для radiogroup)
Home / Endк краям
Space / Enterвыбрать

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

Опции и слот
vue
<GrSegmented v-model="view" :options="options" aria-label="Вид списка" />

Опция — { value, label?, icon?, disabled?, loading?, ariaLabel? }. Иконка декоративна (aria-hidden), поэтому у icon-only сегмента обязателен ariaLabel: иначе у него нет имени вовсе.

Дефолтный слот заменяет содержимое сегмента и получает { option, selected, disabled, loading }.

Занятый сегмент
vue
{ value: 'review', label: 'Review', loading: syncing }

loading показывает спиннер на месте иконки и помечает сегмент aria-busy. Выбор он не принимает, а стрелки его перешагивают — как и disabled.

Разница с disabled смысловая, и она видна скринридеру: занятый сегмент не получает aria-disabled и нативный disabled — он доступен, просто сейчас работает. Недоступный получает и то, и другое.

Только чтение и недоступность

readonly показывает выбор, но не даёт его менять — ни кликом, ни клавиатурой; группа объявляется aria-readonly. disabled гасит весь контрол.

Оба состояния гасятся токеном --gr-disabled-fg, а не прозрачностью: opacity разбавляет выверенные на AA цвета текста.

invalid — свой или пришедший из ошибки GrFormField — красит рамку дорожки и кольцо фокуса ролями ошибки (--gr-invalid-brd, --gr-invalid-ring), как у полей ввода и GrRadioGroup, и ставит aria-invalid. Свой цвет рамки — --gr-segmented-invalid-brd.

Нативная форма

Значение уходит одним скрытым полем рядом с сегментами (name), а не вложенным в каждый role="radio": роль объявляет своих потомков презентационными, и вложенный интерактивный контрол ломает виджет для скринридеров. Недоступный выбранный сегмент значение не отправляет.

Кому достаётся свободное место

block растягивает ряд на контейнер, а itemWidth решает, как он делит ширину:

ЗначениеЧто делает
equal (умолчание)сегменты равны — правильно для «День / Неделя / Месяц»
contentширина от содержимого, свободное место делится поверх неё
vue
<GrSegmented v-model="filter" :options="filters" item-width="content" block />

Равные треки в шторке на 400 px дают три ячейки по 121 px: слову «Все» столько не нужно, а «С документами» не помещается и режется многоточием — при том, что суммарно места достаточно. content раскладывает его туда, где оно нужно.

Умолчание не меняется, и это осознанно. С content ширина ряда зависит от длины подписей, и перевод интерфейса переставлял бы границы сегментов у всех потребителей разом.

Технически режим — это трек minmax(0, auto). Пока места хватает, сегменты получают ширину своего содержимого, а остаток делится поверх поровну. Когда не хватает, треки сжимаются: короткие подписи остаются целиком, длинные делят остаток и уходят в многоточие с полным текстом в title. Шире контейнера ряд не становится никогда.

Минимум трека — ноль, а не min-content: подпись однострочная, и у неё min-content равен max-content — с таким минимумом трек не сжимался вовсе, и ряд выталкивал за край карточку вокруг себя.

Без block и в вертикали проп не значит ничего: там ширина и так по содержимому, а колонка одна.

Форма рамки

Дорожка — пилюля, и это дефолт: так сегменты выглядели всегда. Второе значение — box, то же скругление, что у полей ввода: в форме рядом с GrSelect пилюля читается гостем из другой системы.

vue
<GrSegmented v-model="view" :options="options" shape="box" />

Радиус сегмента внутри дорожки производный (calc(радиус − поле дорожки)), поэтому вторая форма получается сама и отдельной настройки не требует.

В вертикали пилюля считается формулой, а не берёт 9999px: на высокой колонке такой радиус превратил бы дорожку в эллипс, поэтому он равен высоте одного сегмента. box этой поправки не знает — 6 px эллипсом не становятся.

Ось общая с полями ввода — ../sizes.md.

Оформление
ПропЧто делает
variantpills (по умолчанию) или button — тень у дорожки и индикатора
sizexs…lg, читается из GrConfigProvider
orientationhorizontal (по умолчанию) или vertical
blockсегменты растягиваются на всю ширину контейнера
indicatorDurationдлительность анимации индикатора, мс

Точечная кастомизация — через --gr-segmented-* (радиус, отступы, цвета дорожки и индикатора, кегль).

Длинная подпись обрезается, но не пропадает

Подпись сегмента не переносится: ряд обязан оставаться рядом. Не поместившийся хвост прячется многоточием, и «Включено в подписку» превращается в «Включе…».

Полный текст при этом никуда не девается: truncate — правило отрисовки, в DOM строка остаётся целой, и скринридер читает её целиком. Теряет её ровно один читатель — тот, кто смотрит глазами, и ему подпись отдаётся нативной подсказкой при наведении. Подсказка появляется только когда текст действительно обрезан: тултип, дублирующий видимую целиком подпись, — шум, которого никто не просил.

Тот же обработчик доступен снаружи — titleWhenTruncated из пакета, для своей разметки с truncate.

Вертикальный ряд
vue
<GrSegmented v-model="scope" :options="filters" orientation="vertical" block />

Типовой сценарий — боковые фильтры. Ряд разворачивается в колонку, корень объявляет aria-orientation.

Содержимое сегмента в колонке — у начала, а не по центру: подписи и значки разной длины по центру вставали рваной линией, и колонка читалась меню, а не списком. В горизонтальном ряду сегмент по-прежнему центрирован — там он читается кнопкой. Островок от выравнивания не зависит: он меряет сам сегмент и накрывает его целиком.

Индикатору для этого ничего не понадобилось: он измеряется в двух измерениях (translate3d плюс width/height) и едет вниз ровно так же, как вдоль ряда. Смена ориентации на лету пересчитывает геометрию — иначе индикатор остался бы в координатах прежней раскладки.

В вертикали колонка одна, поэтому сегменты одинаковой ширины по построению, а block решает только, занимать ли ширину контейнера.

Радиус дорожки в вертикали считается от высоты сегмента, а не берётся пилюлей: 9999px выверен под короткий ряд и на высокой колонке превращал бы дорожку в эллипс. Сегменты внутри при этом остаются пилюлями — они считают свой радиус от того же значения.

Клавиатура

←/↑ и →/↓ двигают выбор с переносом через край, Home/End — к первому и последнему доступному сегменту. Недоступные и занятые пропускаются.

Обе оси работают в любой ориентации — так требует APG для radiogroup: вертикальный ряд не отключает горизонтальные стрелки и наоборот.