GrButton
Берут, когда действие меняет состояние.
Когда брать
- действие меняет состояние — сохранить, удалить, отправить: адрес при этом не меняется;
- действие идёт на сервер —
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | — |
variant | GrButtonVariant | undefined | undefined | — |
type | "button" | "submit" | "reset" | undefined | "button" | — |
disabled | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
loading | boolean | undefined | false | — |
loadingText | string | undefined | undefined | i18n: что именно грузится. `aria-busy` сам по себе часть AT не объявляет. |
square | boolean | undefined | undefined | — |
as | string | Component | undefined | undefined | Полиморфизм: кастомный корневой тег/компонент (например, RouterLink). |
block | boolean | undefined | false | Кнопка на всю ширину контейнера. |
href | string | undefined | undefined | Рендерит кнопку как `<a href>` (если не задан `as`). |
target | string | undefined | undefined | — |
rel | string | undefined | undefined | — |
external | boolean | undefined | false | — |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Содержимое кнопки. |
prefix | any | Аддон слева: иконка, счётчик. В состоянии загрузки уступает место спиннеру. |
suffix | any | Аддон справа: шеврон, счётчик. |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Примеры 3
Слоты, block и состояния
#prefix/#suffix вместо одного слота, block на всю ширину, объявление загрузки и одинаковое приглушение у отключённых кнопки и ссылки.
<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-картами.
<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('&', '&').replaceAll('"', '"')
}
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 для дизайн-ревью и визуальной регрессии.
tone: primary
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: neutral
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: success
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: warning
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: danger
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: info
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: slate
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
tone: azure
Live + 4 states × 5 variants| state \ variant | primary | secondary | outline | ghost | ghost-border |
|---|---|---|---|---|---|
| Live | |||||
| Rest | |||||
| Hover | |||||
| Focus | |||||
| Active |
Это тот же сценарий, который раньше жил в playground-5: удобно сравнивать новые tones и проверять state-contract без ручного наведения.