GrButton

Пакет: @feugene/granularityядроГруппа: Действия

Берут, когда действие меняет состояние.

Когда брать

  • действие меняет состояние — сохранить, удалить, отправить: адрес при этом не меняется;
  • действие идёт на серверloading блокирует повтор и объявляет ожидание;
  • важность действия виднаvariant и tone разводят главное, второстепенное и опасное;
  • кнопка только из иконкиsquare вместе с ariaLabel: без имени такая кнопка безымянна;
  • нужна ссылка с видом кнопкиhref/as подменяют тег, оставляя семантику ссылки.

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

НужноБерите
Переход меняет адресGrLink
Действий несколько в рядGrButtonGroup
Действий много, они прячутсяGrDropdownMenu
Переключается состояние «включено/выключено»GrSwitch
Переключается вид одного содержимогоGrSegmented

`disabled` и `loading` — разные состояния

disabled ставит нативный атрибут (у <button>) и гасит кнопку токенами --gr-button-disabled-bg / -fg / -brd. Прозрачности здесь нет намеренно: opacity разбавляет выверенные на AA цвета, а кнопка чаще других компонентов стоит на цветной подложке, где разбавленный текст проваливается первым.

Сами эти токены — ссылки на общие роли --gr-disabled-bg/-fg/-brd, те же, что у вкладки, сегмента и бегунка: недоступное состояние во всём пакете выглядит одинаково и перекрашивается одним местом. Покомпонентная тройка остаётся точкой кастомизации на случай, когда кнопка обязана отличаться.

Приглушение работает и для кнопки-ссылки (href/as): нативный disabled ей не достаётся, поэтому цвета задаются классами, а не disabled:-вариантом.

loading не ставит нативный disabled — элемент остался бы без фокуса, и скринридер потерял бы контекст. Вместо этого aria-busy, aria-disabled и перехват клика в capture-фазе. Выглядит кнопка при этом обычной: о состоянии говорит спиннер.

<GrButton :loading="saving" loading-text="Сохраняем отчёт">
Сохранить
</GrButton>

aria-busy сам по себе объявляет не всякая AT, поэтому во время загрузки к имени кнопки добавляется скрытый суффикс — loadingText или ключ gr.button.loading.

Слоты

<GrButton>
  <template #prefix><GrIcon><IconPlus /></GrIcon></template>
  Добавить
  <template #suffix><GrKbd keys="mod+N" /></template>
</GrButton>

Раньше иконка и текст валились в один слот, и порядок держался дисциплиной потребителя. Во время loading спиннер занимает место префикса, а #prefix не рендерится: две иконки рядом читаются как ошибка вёрстки.

Квадратная кнопка и `block`

square даёт кнопку с равными сторонами — размер приходит из --gr-button-square-size с дефолтом по размеру (1.75rem / 2rem / 2.5rem / 2.75rem). Переменную можно задать в своём CSS и подогнать кнопку под свою сетку:

.toolbar { --gr-button-square-size: 2.25rem; }

Переменная одна на все четыре ступени. Заданная выше по дереву — на :root, в теме — она схлопывает xs, sm, md и lg в одно число, и ступень перестаёт что-либо значить. Поэтому её место — узкий скоуп вроде примера выше, а не корень документа. Нужен размер по устройству ввода — задавайте его GrConfigProvider, а не переменной: приложение знает про свой указатель, пакет про него не знает.

block растягивает кнопку на ширину контейнера.

Полиморфный корень

as<a href><button>. Ссылке автоматически достаётся rel="noopener noreferrer", если она открывается в новой вкладке (external или явный target="_blank"), а disabled убирает её из таб-порядка.

Playground 13

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

Код
<GrButton />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefined
variantGrButtonVariant | undefinedundefined
type"button" | "submit" | "reset" | undefined"button"
disabledboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
loadingboolean | undefinedfalse
loadingTextstring | undefinedundefinedi18n: что именно грузится. `aria-busy` сам по себе часть AT не объявляет.
squareboolean | undefinedundefined
asstring | Component | undefinedundefinedПолиморфизм: кастомный корневой тег/компонент (например, RouterLink).
blockboolean | undefinedfalseКнопка на всю ширину контейнера.
hrefstring | undefinedundefinedРендерит кнопку как `<a href>` (если не задан `as`).
targetstring | undefinedundefined
relstring | undefinedundefined
externalboolean | undefinedfalse

Slots

SlotTypeОписание
defaultanyСодержимое кнопки.
prefixanyАддон слева: иконка, счётчик. В состоянии загрузки уступает место спиннеру.
suffixanyАддон справа: шеврон, счётчик.

Methods / Expose

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

Примеры 3

Слоты, block и состояния

#prefix/#suffix вместо одного слота, block на всю ширину, объявление загрузки и одинаковое приглушение у отключённых кнопки и ссылки.

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

import { GrButton, GrIcon, GrKbd } from '@feugene/granularity'
import IconPlus from '~icons/lucide/plus'

const saving = ref(false)

async function save(): Promise<void> {
  saving.value = true
  await new Promise(resolve => setTimeout(resolve, 1200))
  saving.value = false
}
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-3">
      <!-- Иконка и текст больше не валятся в один слот. -->
      <GrButton>
        <template #prefix>
          <GrIcon size="sm" aria-hidden="true">
            <IconPlus />
          </GrIcon>
        </template>
        Добавить проект
        <template #suffix>
          <GrKbd keys="mod+N" size="xs" />
        </template>
      </GrButton>

      <!-- Во время загрузки спиннер занимает место префикса. -->
      <GrButton :loading="saving" loading-text="Сохраняем отчёт" @click="save">
        <template #prefix>
          <GrIcon size="sm" aria-hidden="true">
            <IconPlus />
          </GrIcon>
        </template>
        Сохранить
      </GrButton>

      <GrButton disabled>
        Отключённая кнопка
      </GrButton>

      <GrButton href="https://example.com" disabled>
        Отключённая ссылка
      </GrButton>
    </div>

    <GrButton block variant="outline">
      Кнопка на всю ширину
    </GrButton>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Отключённые кнопка и ссылка гасятся одной парой токенов — раньше ссылке не доставалось ничего,
      потому что нативный `disabled` к ней неприменим. Во время загрузки к имени кнопки добавляется
      скрытый текст: `aria-busy` сам по себе объявляет не всякий скринридер.
    </div>
  </div>
</template>

Интерактивный конструктор кнопки

Живой playground для всех ключевых пропсов GrButton: меняйте variant, tone, size, type и состояния без переключения между отдельными demo-картами.

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

import {
  GrButton,
  GrFormField,
  GrInput,
  GrRadioGroup,
  GrSelect,
  GrSwitch,
  GrCard,
  type GrButtonSize,
  type GrButtonTone,
  type GrButtonVariant,
} from '@feugene/granularity'
import IconSparkles from '~icons/lucide/sparkles'

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

type GrButtonType = 'button' | 'submit' | 'reset'

const variant = ref<GrButtonVariant>('primary')
const tone = ref<GrButtonTone>('primary')
const size = ref<GrButtonSize>('md')
const type = ref<GrButtonType>('button')
const label = ref('Create workspace')
const ariaLabel = ref('Create workspace')
const loading = ref(false)
const disabled = ref(false)
const square = ref(false)

const variantOptions = [
  { value: 'primary', label: 'Primary' },
  { value: 'secondary', label: 'Secondary' },
  { value: 'outline', label: 'Outline' },
  { value: 'ghost', label: 'Ghost' },
  { value: 'ghost-border', label: 'Ghost border' },
] satisfies Array<{ value: GrButtonVariant, label: string }>

const toneOptions = [
  { value: 'primary', label: 'Primary' },
  { value: 'neutral', label: 'Neutral' },
  { value: 'success', label: 'Success' },
  { value: 'warning', label: 'Warning' },
  { value: 'danger', label: 'Danger' },
  { value: 'info', label: 'Info' },
  { value: 'slate', label: 'Slate' },
  { value: 'azure', label: 'Azure' },
] satisfies Array<{ value: GrButtonTone, label: string }>

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

const typeOptions = [
  { value: 'button', label: 'button' },
  { value: 'submit', label: 'submit' },
  { value: 'reset', label: 'reset' },
] satisfies Array<{ value: GrButtonType, label: string }>

const buttonText = computed(() => {
  if (loading.value && !square.value)
    return 'Saving…'

  return label.value.trim() || 'Create workspace'
})

const effectiveAriaLabel = computed(() => {
  return ariaLabel.value.trim() || buttonText.value
})

const previewSummary = computed(() => {
  if (square.value)
    return 'Square mode makes the button icon-only, so `aria-label` should describe the action for screen readers.'

  if (loading.value)
    return 'Loading automatically disables the button and helps prevent repeated submit actions in async scenarios.'

  if (disabled.value)
    return 'Disabled preserves the visual contract of the selected variant/tone while turning off interactivity and pointer events.'

  if (variant.value === 'ghost' || variant.value === 'ghost-border')
    return 'Ghost variants work best in toolbars and dense action areas where a filled CTA would feel too heavy.'

  return 'Combine `variant`, `tone`, `size`, and `type` to quickly verify the button contract before shipping it to a product scenario.'
})

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

const previewCode = computed(() => {
  const attributes = [
    `variant="${variant.value}"`,
    `tone="${tone.value}"`,
    `size="${size.value}"`,
    `type="${type.value}"`,
  ]

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

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

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

  if (square.value || effectiveAriaLabel.value !== buttonText.value)
    attributes.push(`aria-label="${escapeAttribute(effectiveAriaLabel.value)}"`)

  const content = square.value && !loading.value
    ? '  <IconSparkles class="h-4 w-4" aria-hidden="true" />'
    : `  ${buttonText.value}`

  return ['<GrButton', ...attributes.map(attribute => `  ${attribute}`), '>', content, '</GrButton>'].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>

          <GrButton
              :variant="variant"
              :tone="tone"
              :size="size"
              :type="type"
              :loading="loading"
              :disabled="disabled"
              :square="square"
              :aria-label="effectiveAriaLabel"
          >
            <IconSparkles v-if="square && !loading" class="h-4 w-4" aria-hidden="true" />
            <template v-else>
              {{ buttonText }}
            </template>
          </GrButton>

          <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-[40ch] 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">
        Properties
      </div>

      <div class="grid gap-4">
        <GrFormField label="Variant">
          <GrSelect v-model="variant" :options="variantOptions" aria-label="Variant" />
        </GrFormField>

        <GrFormField label="Tone">
          <GrSelect v-model="tone" :options="toneOptions" aria-label="Tone" />
        </GrFormField>

        <GrFormField label="Size">
          <GrRadioGroup v-model="size" :options="sizeOptions" variant="button" size="sm" />
        </GrFormField>

        <GrFormField label="Type">
          <GrRadioGroup v-model="type" :options="typeOptions" variant="button" size="sm" />
        </GrFormField>

        <GrFormField label="Button label">
          <GrInput
              v-model="label"
              :disabled="square"
              placeholder="Create workspace"
              aria-label="Button label"
          />
        </GrFormField>

        <GrFormField label="Accessibility label">
          <GrInput
              v-model="ariaLabel"
              :placeholder="square ? 'Required for icon-only state' : 'Optional override for screen readers'"
              aria-label="Accessibility label"
          />
        </GrFormField>
      </div>

      <GrCard class="grid gap-3 p-4">
        <GrSwitch v-model="loading" size="sm">
          Loading
        </GrSwitch>
        <GrSwitch v-model="disabled" size="sm">
          Disabled
        </GrSwitch>
        <GrSwitch v-model="square" size="sm">
          Square / icon-only
        </GrSwitch>
      </GrCard>
    </div>
  </div>
</template>

Лучший формат для дизайн-ревью и QA: один сценарий сразу покрывает все пропсы компонента и помогает быстро проверить доступность icon-only режима.

Матрица состояний: тон × вариант

Полная матрица по всем tone и variant, включая live, hover, focus и active для дизайн-ревью и визуальной регрессии.

The same matrix that lived in `playground-5`: for each `tone` you can see live buttons and force-rendered `hover`, `focus`, and `active` states across all `variant`s.

tone: primary

Live + 4 states × 5 variants
state \ variant primarysecondaryoutlineghostghost-border
Live
Rest
Hover
Focus
Active

tone: neutral

Live + 4 states × 5 variants
state \ variant primarysecondaryoutlineghostghost-border
Live
Rest
Hover
Focus
Active

tone: success

Live + 4 states × 5 variants
state \ variant primarysecondaryoutlineghostghost-border
Live
Rest
Hover
Focus
Active

tone: warning

Live + 4 states × 5 variants
state \ variant primarysecondaryoutlineghostghost-border
Live
Rest
Hover
Focus
Active

tone: danger

Live + 4 states × 5 variants
state \ variant primarysecondaryoutlineghostghost-border
Live
Rest
Hover
Focus
Active

tone: info

Live + 4 states × 5 variants
state \ variant primarysecondaryoutlineghostghost-border
Live
Rest
Hover
Focus
Active

tone: slate

Live + 4 states × 5 variants
state \ variant primarysecondaryoutlineghostghost-border
Live
Rest
Hover
Focus
Active

tone: azure

Live + 4 states × 5 variants
state \ variant primarysecondaryoutlineghostghost-border
Live
Rest
Hover
Focus
Active

Это тот же сценарий, который раньше жил в playground-5: удобно сравнивать новые tones и проверять state-contract без ручного наведения.

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