GrSwitch

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

Берут, когда настройка включается сразу.

Когда брать

  • настройка включается сразу — уведомления, тёмная тема, доступ по ссылке: кнопки «Сохранить» рядом нет;
  • применение идёт на серверloading держит состояние на время запроса, не давая переключить дважды;
  • состояние важнее выбора — «включено/выключено» читается быстрее пары вариантов;
  • значение не булевоvalue задаёт, что уходит наружу во включённом положении.

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

НужноБерите
Значение уедет с отправкой формыGrCheckbox
Вариантов два, но это режимы отображенияGrSegmented
Вариантов больше двухGrRadioGroup
Значений несколькоGrCheckboxGroup

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

<GrSwitch v-model="notifications" name="notifications" value="on">
  Email-уведомления
</GrSwitch>

Семантика чекбокса: включённый переключатель отправляет name=value, выключенный не отправляется вовсе — сервер отличает «выкл» по отсутствию ключа. Без name скрытого поля нет; form связывает переключатель с формой, если он лежит вне неё.

Внутри <button> интерактивный контент невалиден, поэтому скрытое поле — сосед кнопки, а корень компонента — фрагмент. class и прочие атрибуты по-прежнему садятся на кнопку.

Загрузка

<GrSwitch :model-value="backup" :loading="syncing" @change="save" />

loading показывает спиннер в бегунке, помечает контрол aria-busy и блокирует переключение, пока запрос в полёте. Что именно грузится, задаёт loadingText (по умолчанию — ключ gr.switch.loading): aria-busy сам по себе часть скринридеров не объявляет.

Подпись

Подпись приходит слотом по умолчанию, labelPosition="start" переносит её влево от дорожки. Ряд именно разворачивается, а не переставляется в DOM: диктор читает порядок узлов, и дорожка должна оставаться первой.

Без подписи имя обязателен задать иначе — ariaLabel или GrFormField, который свяжет <label for> с кнопкой.

Состояния

ПропЧто делает
disabledгасит контрол токенами --gr-disabled-bg / -brd / -fg; они перебивают и кастомные цвета дорожки
readonlyсостояние видно, но не меняется (aria-readonly)
invalid, requiredaria-invalid / aria-required; складываются с контекстом GrFormField

Недоступность гасится фоном, а не opacity: прозрачность разбавляет выверенные на AA токены текста и роняет контраст подписи.

События и клавиатура

update:modelValue и change эмитятся вместе. Экземпляр отдаёт focus() и blur().

Клавиатура нативная: контрол — настоящая <button>, поэтому Space и Enter переключают его силами браузера, а Tab обходит в общем порядке.

Оформление

sizexslg, читается из GrConfigProvider (componentDefaults.GrSwitch.size). Цвет дорожки точечно задаётся пропами activeBackgroundColor / inactiveBackgroundColor.

Playground 15

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

Код
<GrSwitch />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
modelValueboolean | undefinedfalseЗначение переключателя. Необязательное: без `v-model` контрол рисуется выключенным — так же, как `GrCheckbox`. Обязательный проп заставлял бы заводить `ref` даже там, где переключатель presentational.
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseТолько для чтения: состояние видно, но не переключается.
invalidboolean | undefinedfalseВизуальное и ARIA-состояние ошибки.
requiredboolean | undefinedfalseОбязательное поле (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
loadingboolean | undefinedfalseИдёт сохранение: бегунок показывает спиннер, переключение заблокировано.
loadingTextstring | undefinedundefinedi18n: что именно грузится. `aria-busy` сам по себе часть AT не объявляет.
namestring | undefinedundefinedИмя поля для нативной отправки формы. Без него скрытое поле не рендерится.
valuestring | undefined"on"Значение, уходящее в форму во включённом состоянии.
formstring | undefinedundefined`id` формы, если переключатель лежит вне неё.
labelPosition"end" | "start" | undefined"end"Сторона подписи относительно дорожки.
activeBackgroundColorstring | undefinedundefinedКастомный цвет фона в активном состоянии. Если не задан — `var(--gr-primary)`.
inactiveBackgroundColorstring | undefinedundefinedКастомный цвет фона в неактивном состоянии. Если не задан — `var(--gr-muted)`.

Slots

SlotTypeОписание
defaultanyПодпись переключателя.

Events

EventTypeОписание
update:modelValue[value: boolean]
change[value: boolean]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

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

Примеры 4

Интерактивный конструктор переключателя

Соберите GrSwitch под ваш сценарий: меняйте состояние, size, подпись и локальные color overrides, сразу получая итоговый snippet.

Builderзависит от окружения витрины
<script setup lang="ts">
import { computed, ref } from 'vue'

import {
  GrFormField,
  GrInput,
  GrRadioGroup,
  GrSwitch,
  type GrSwitchSize,
} from '@feugene/granularity'

import CodeBlock from '../../../components/doc/CodeBlock.vue'

const checked = ref(true)
const disabled = ref(false)
const size = ref<GrSwitchSize>('md')
const label = ref('Email notifications')
const ariaLabel = ref('')
const activeBackgroundColor = ref('')
const inactiveBackgroundColor = ref('')

const sizeOptions = [
  { value: 'xs', label: 'XS' },
  { value: 'sm', label: 'SM' },
  { value: 'md', label: 'MD' },
  { value: 'lg', label: 'LG' },
] satisfies Array<{ value: GrSwitchSize, label: string }>

const switchText = computed(() => {
  return label.value.trim() || 'Email notifications'
})

const resolvedAriaLabel = computed(() => {
  return ariaLabel.value.trim() || undefined
})

const resolvedActiveBackgroundColor = computed(() => {
  return activeBackgroundColor.value.trim() || undefined
})

const resolvedInactiveBackgroundColor = computed(() => {
  return inactiveBackgroundColor.value.trim() || undefined
})

const previewSummary = computed(() => {
  if (disabled.value)
    return 'A disabled switch blocks state changes but keeps the visual context of the current setting.'

  if (resolvedActiveBackgroundColor.value || resolvedInactiveBackgroundColor.value) {
    return 'Local color overrides help embed the switch into a special scenario without changing global theme tokens.'
  }

  if (checked.value)
    return 'In the enabled state the track uses the primary accent and works well for key feature toggles.'

  return 'Pick the size, label and optional accessibility/color props to quickly assemble the switch contract you need.'
})

function escapeAttribute(value: string) {
  return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;')
}

const previewCode = computed(() => {
  const attributes = [
    `:model-value="${checked.value ? 'true' : 'false'}"`,
    `size="${size.value}"`,
  ]

  if (disabled.value)
    attributes.push('disabled')

  if (resolvedAriaLabel.value && resolvedAriaLabel.value !== switchText.value) {
    attributes.push(`aria-label="${escapeAttribute(resolvedAriaLabel.value)}"`)
  }

  if (resolvedActiveBackgroundColor.value) {
    attributes.push(`active-background-color="${escapeAttribute(resolvedActiveBackgroundColor.value)}"`)
  }

  if (resolvedInactiveBackgroundColor.value) {
    attributes.push(`inactive-background-color="${escapeAttribute(resolvedInactiveBackgroundColor.value)}"`)
  }

  return ['<GrSwitch', ...attributes.map(attribute => `  ${attribute}`), '>', `  ${switchText.value}`, '</GrSwitch>'].join('\n')
})
</script>

<template>
  <div class="grid gap-4 xl:grid-cols-[minmax(0,1.15fr)_320px]">
    <div class="grid gap-4">
      <div
          class="relative grid min-h-[280px] rounded-[24px] border border-dashed border-[var(--preview-brd)] bg-[image:var(--preview-surface)] p-6 pb-[72px]"
>
        <div class="flex h-full flex-col items-center justify-center gap-4 text-center">
          <div class="showcase-demo-caption text-xs">
            Preview
          </div>

          <GrSwitch
              :model-value="checked"
              :disabled="disabled"
              :size="size"
              :aria-label="resolvedAriaLabel"
              :active-background-color="resolvedActiveBackgroundColor"
              :inactive-background-color="resolvedInactiveBackgroundColor"
              @update:model-value="checked = $event"
          >
            {{ switchText }}
          </GrSwitch>

          <div class="pointer-events-none absolute inset-x-6 bottom-6 flex justify-center border-t border-dashed border-[var(--preview-brd)] pt-2">
            <div class="showcase-demo-text max-w-[42ch] text-center text-sm">
              {{ previewSummary }}
            </div>
          </div>
        </div>
      </div>

      <CodeBlock :code="previewCode" language="vue" expanded title="Rendered snippet" />
    </div>

    <div class="showcase-demo-panel grid gap-4 rounded-[28px] border p-4 lg:p-5">
      <div class="showcase-demo-title text-sm font-semibold">
        Switch properties
      </div>

      <div class="grid gap-4">
        <GrFormField label="Size">
          <GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
        </GrFormField>

        <GrFormField label="Label">
          <GrInput
              v-model="label"
              placeholder="Email notifications"
              aria-label="Switch label"
          />
        </GrFormField>

        <GrFormField label="Accessibility label">
          <GrInput
              v-model="ariaLabel"
              placeholder="Used when the visible label is not enough"
              aria-label="Switch accessibility label"
          />
        </GrFormField>

        <GrFormField label="Active background color">
          <GrInput
              v-model="activeBackgroundColor"
              placeholder="#22c55e / var(--gr-primary)"
              aria-label="Switch active background color"
          />
        </GrFormField>

        <GrFormField label="Inactive background color">
          <GrInput
              v-model="inactiveBackgroundColor"
              placeholder="#e5e7eb / var(--gr-muted)"
              aria-label="Switch inactive background color"
          />
        </GrFormField>
      </div>

      <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
        <GrSwitch v-model="checked" size="sm">
          Checked
        </GrSwitch>
        <GrSwitch v-model="disabled" size="sm">
          Disabled
        </GrSwitch>
      </div>
    </div>
  </div>
</template>

Шкала размеров: от компактного до заметного

Один сценарий показывает, как переключатель масштабируется от компактных control bars до больших form-sections без изменения поведения.

Sizes
<script setup lang="ts">
import { ref } from 'vue'

import { GrSwitch } from '@feugene/granularity'

const xsValue = ref(false)
const smValue = ref(false)
const mdValue = ref(true)
const lgValue = ref(true)
</script>

<template>
  <div class="flex flex-wrap items-center gap-6">
    <GrSwitch v-model="xsValue" size="xs">Extra small</GrSwitch>
    <GrSwitch v-model="smValue" size="sm">Small</GrSwitch>
    <GrSwitch v-model="mdValue" size="md">Medium</GrSwitch>
    <GrSwitch v-model="lgValue" size="lg">Large</GrSwitch>
  </div>
</template>

Переключатели с подписью и выключенное состояние

Показываем, что label живёт в default slot, а disabled-режим одинаково корректно работает и для управляемого, и для статически включённого switch.

Disabled
<script setup lang="ts">
import { ref } from 'vue'

import { GrSwitch } from '@feugene/granularity'

const notifications = ref(true)
const disabled = ref(false)

const syncing = ref(false)
const backup = ref(false)

function saveBackup(value: boolean): void {
  syncing.value = true
  window.setTimeout(() => {
    backup.value = value
    syncing.value = false
  }, 1200)
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
    <div class="grid gap-3">
      <GrSwitch v-model="notifications" :disabled="disabled">
        Email notifications
      </GrSwitch>
      <GrSwitch :model-value="true" disabled>
        Always on
      </GrSwitch>
      <GrSwitch
        :model-value="backup"
        :loading="syncing"
        label-position="start"
        @change="saveBackup"
      >
        Automatic backup
      </GrSwitch>
    </div>

    <div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <GrSwitch v-model="disabled" size="sm">
        Disable labeled switch
      </GrSwitch>
    </div>
  </div>
</template>

Свои цвета включённого и выключенного

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

Colors
<script setup lang="ts">
import { ref } from 'vue'

import { GrInput, GrSwitch } from '@feugene/granularity'

const enabled = ref(true)
const activeBackgroundColor = ref('#22c55e')
const inactiveBackgroundColor = ref('#e5e7eb')
</script>

<template>
  <div class="grid gap-4">
    <GrSwitch
      v-model="enabled"
      :active-background-color="activeBackgroundColor"
      :inactive-background-color="inactiveBackgroundColor"
    >
      Custom colors
    </GrSwitch>

    <div class="grid gap-3 md:grid-cols-2">
      <GrInput v-model="activeBackgroundColor" placeholder="#22c55e / var(--gr-primary)" />
      <GrInput v-model="inactiveBackgroundColor" placeholder="#e5e7eb / var(--gr-muted)" />
    </div>
  </div>
</template>

Доступность

Паттерн APG
switch
Клавиши
Space, Enter — переключить (нативная <button role="switch">)

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

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