GrBadge

Пакет: @feugene/granularityядроГруппа: Обратная связь

Берут, когда статус нужен словом.

Когда брать

  • статус нужен словом — «Оплачено», «Черновик», «Просрочен»: тон несёт смысл, а не только цвет;
  • метка стоит в потоке — в строке таблицы, в карточке, рядом с заголовком;
  • категорий много — вся шкала тонов пакета без своих цветов;
  • метка ничего не делает — компонент презентационный: не кликается и никуда не ведёт.

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

НужноБерите
Счётчик поверх кнопки или иконкиGrBadgeWrap
Метка снимается или выбираетсяGrChip; в поле ввода — GrInputTag
Нужно действиеGrButton
Число как показательGrStatistic
Клавиша или сочетаниеGrKbd

Два веса: подложка и заливка

dark переключает мягкую тонированную подложку на плотную заливку.

Заливка идёт через покомпонентный слой --gr-badge-{tone}-bg/-fg, а не прямо из роли тона, потому что нужный вес у тем разный. В светлой теме --gr-{tone} — это яркая заливка под тёмный текст, и filled-бейдж читался тяжёлой почти чёрной плашкой; там слой уводит его на -solid/-solid-fg — заливку кнопочного веса под светлый текст, ту же, что у solid-кнопок. В тёмной теме пастельная заливка с тёмным текстом — штатная конвенция пакета, и слой оставляет прежние роли.

Значения слоя лежат в темах компонента (GrBadge/themes/{light,dark}.css), поэтому перекрасить бейдж можно, не трогая роли тонов целиком.

Почему текст на подложке — всегда `-text`

Насыщенный тон как цвет текста на своей же светлой подложке даёт 2.24:1, роль -text — 6.78:1. Поэтому мягкий вес красит текст только -text-ролями.

Обе гарантии закреплены гейтом grBadgeContrast.test.ts, и обе не формальные:

  • контраст текста — порог AA 4.5:1. Послаблений для крупного текста здесь нет: кегль бейджа 11–14px;
  • видимость самой плашки меряется перцептивным расстоянием до фона страницы (ΔE, порог заметности 2.3). Контрастного правила для этого мало: 3:1 из WCAG 1.4.11 не проходит даже штатная пара --gr-muted/--gr-brd, и бейдж мог оказаться неотличимым от страницы, формально не нарушив ничего.

Про роли тонов и суффиксы — ../theming.md.

Радиус

radiusround (по умолчанию), semi или square. У semi своя лестница по ступеням размера — 3/3/5/7 px через --gr-badge-semi-radius-*: бейдж скругляется медленнее, чем растёт, иначе на lg он превращается в пилюлю раньше, чем этого хочет макет.

<GrBadge radius="semi" size="lg">
SKU-14920
</GrBadge>

Текст центрируется оптически

Высоту бейджа держит внутренняя обёртка (min-height: 1lh — высота line-box при leading-none), а сам текст обрезается до cap-height (text-box-trim) и центрируется внутри этой высоты. Так подпись стоит ровно независимо от того, есть ли в ней выносные элементы: «Оплачено» и «Ждём оплаты» садятся одинаково.

Обрезка живёт на вложенном узле, а не на самом тексте, и это не стилистика: text-box-trim уменьшает line-box, и повешенный прямо на текст он просадил бы вместе с ним высоту всего бейджа.

Иконка перед подписью — слот `icon`

Статус часто состоит не только из слова: «идёт разбор» без спиннера читается как окончательное состояние, а не как работа.

<GrBadge tone="warning">
  <template #icon>
    <IconLoader class="h-full w-full animate-spin" />
  </template>
  На распознании
</GrBadge>

Размер держит обёртка слота, а содержимое тянется до неё (h-full w-full) — тот же приём, что у GrChip, и по той же причине: шкала иконки обязана следовать за size бейджа, а не за разметкой места вызова. Растёт она медленнее кегля: на нижних ступенях иконка «один в один с текстом» разогнала бы узкую плашку.

Иконка — сосед обрезанного текста, а не его содержимое: text-box-trim живёт на вложенном узле подписи и до иконки не дотягивается, поэтому высоту плашки она не тянет.

Не кладите сюда GrProgressCircle: у кругового индикатора шкала виджетная, нижняя ступень — 2rem, и в строку бейджа он не помещается — дуга уезжает за подложку, вынося текст наружу. Спиннер здесь — обычная иконка с animate-spin.

Слот не делает бейдж интерактивным: он по-прежнему метка. Нужен снимаемый или выбираемый тег — это GrChip, у него своя роль и своя клавиатура.

Размер

size берёт кегль из контрольной шкалы --gr-control-text-*, а не из контентной: бейдж чаще стоит в одном ряду с кнопками и полями, чем внутри абзаца. Шкалы и их разница — ../sizes.md.

tone, size и radius читаются из GrConfigProvider, поэтому вид бейджей задаётся один раз на приложение.

Playground 3

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

Код
<GrBadge />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefined
size"xs" | "sm" | "md" | "lg" | undefinedundefined
darkboolean | undefinedfalse
radiusGrBadgeRadius | undefinedundefined

Slots

SlotTypeОписание
defaultanyСодержимое метки.
iconanyИконка перед подписью: статус со спиннером, флаг, значок типа. Размер задаёт обёртка по ступени `size`, поэтому содержимое слота тянется до неё — `class="h-full w-full"`, как у `GrChip`. Вращение спиннера — `animate-spin` на самой иконке. `GrProgressCircle` сюда не кладут: у кругового индикатора шкала виджетная, нижняя ступень — `2rem`, и в строку бейджа он не помещается.

Примеры 5

Интерактивный конструктор бейджа

Соберите GrBadge под ваш сценарий: переключайте tone, size, radius, filled-mode и текст лейбла, сразу видя итоговый snippet.

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

import {
  GrBadge,
  GrFormField,
  GrInput,
  GrRadioGroup,
  GrSelect,
  GrSwitch,
  type GrBadgeRadius,
  type GrBadgeSize,
  type GrBadgeTone,
} from '@feugene/granularity'

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

const tone = ref<GrBadgeTone>('primary')
const size = ref<GrBadgeSize>('sm')
const radius = ref<GrBadgeRadius>('round')
const label = ref('Beta')
const dark = ref(false)
const uppercase = ref(false)

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

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

const radiusOptions = [
  { value: 'square', label: 'Square' },
  { value: 'semi', label: 'Semi' },
  { value: 'round', label: 'Round' },
] satisfies Array<{ value: GrBadgeRadius, label: string }>

const badgeText = computed(() => {
  const value = label.value.trim()

  return value || 'Beta'
})

const previewSummary = computed(() => {
  if (dark.value)
    return 'A filled (`dark`) badge works better as a bright status indicator inside tables, toolbar counters and alert summaries.'

  if (radius.value === 'square')
    return 'A square badge gives the densest silhouette and suits compact row-level labels and counters.'

  if (radius.value === 'semi')
    return 'The semi radius is visually closer to a filter chip and is handy where you need a slightly stricter outline without a full pill effect.'

  if (tone.value === 'neutral')
    return 'A neutral light badge is a safe default for metadata, secondary statuses and supporting captions.'

  return 'Assemble the right combination of `tone`, `size`, `radius` and `dark` to quickly check the badge before using it in the interface.'
})

const previewLabelClass = computed(() => {
  return uppercase.value ? 'uppercase tracking-[0.08em]' : ''
})

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

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

  const content = uppercase.value ? badgeText.value.toUpperCase() : badgeText.value

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

<template>
  <div class="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_320px]">
    <div class="grid gap-4">
      <div
          class="relative grid min-h-[240px] 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-5 text-center">
          <div class="showcase-demo-caption text-xs">
            Preview
          </div>

          <div class="flex flex-wrap items-center justify-center gap-3">
            <GrBadge :tone="tone" :size="size" :radius="radius" :dark="dark">
              <span :class="previewLabelClass">{{ uppercase ? badgeText.toUpperCase() : badgeText }}</span>
            </GrBadge>

            <span class="showcase-demo-text text-sm text-[var(--gr-muted-fg)]">
              SLA status
            </span>
          </div>

          <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-[44ch] 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">
        Badge properties
      </div>

      <div class="grid gap-4">
        <GrFormField label="Tone">
          <GrSelect v-model="tone" :options="toneOptions" aria-label="Badge tone" />
        </GrFormField>

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

        <GrFormField label="Radius">
          <GrRadioGroup v-model="radius" :options="radiusOptions" variant="button" size="sm" />
        </GrFormField>

        <GrFormField label="Label">
          <GrInput v-model="label" placeholder="Beta" aria-label="Badge label" />
        </GrFormField>
      </div>

      <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
        <GrSwitch v-model="dark" size="sm">
          Filled / dark mode
        </GrSwitch>
        <GrSwitch v-model="uppercase" size="sm">
          Uppercase label
        </GrSwitch>
      </div>
    </div>
  </div>
</template>

Светлые и плотные семантические тона

Сценарий работает как справочник по semantic palette: light и filled (dark) режимы удобно сравнить бок о бок, включая slate и azure.

NeutralPrimaryInfoSuccessWarningDangerSlateAzure
NeutralPrimaryInfoSuccessWarningDangerSlateAzure

Variant Scale
<script setup lang="ts">
import { GrBadge } from '@feugene/granularity'
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-2">
      <GrBadge>Neutral</GrBadge>
      <GrBadge tone="primary">Primary</GrBadge>
      <GrBadge tone="info">Info</GrBadge>
      <GrBadge tone="success">Success</GrBadge>
      <GrBadge tone="warning">Warning</GrBadge>
      <GrBadge tone="danger">Danger</GrBadge>
      <GrBadge tone="slate">Slate</GrBadge>
      <GrBadge tone="azure">Azure</GrBadge>
    </div>

    <div class="flex flex-wrap items-center gap-2">
      <GrBadge dark>Neutral</GrBadge>
      <GrBadge tone="primary" dark>Primary</GrBadge>
      <GrBadge tone="info" dark>Info</GrBadge>
      <GrBadge tone="success" dark>Success</GrBadge>
      <GrBadge tone="warning" dark>Warning</GrBadge>
      <GrBadge tone="danger" dark>Danger</GrBadge>
      <GrBadge tone="slate" dark>Slate</GrBadge>
      <GrBadge tone="azure" dark>Azure</GrBadge>
    </div>
  </div>
</template>

Сочетания размера и радиуса

Отдельно выделяем size и radius, чтобы quickly show pill/semi/square badges для table cells, filters и inline labels.

xs squarexs semixs roundsm squaresm semism roundmd squaremd semimd roundlg squarelg semilg round

Size Radius
<script setup lang="ts">
import { GrBadge } from '@feugene/granularity'
</script>

<template>
  <div class="flex flex-wrap items-center gap-2">
    <GrBadge size="xs" tone="success" radius="square">xs square</GrBadge>
    <GrBadge size="xs" tone="success" radius="semi">xs semi</GrBadge>
    <GrBadge size="xs" tone="success" radius="round">xs round</GrBadge>

    <GrBadge size="sm" tone="success" radius="square">sm square</GrBadge>
    <GrBadge size="sm" tone="success" radius="semi">sm semi</GrBadge>
    <GrBadge size="sm" tone="success" radius="round">sm round</GrBadge>

    <GrBadge size="md" tone="warning" radius="square">md square</GrBadge>
    <GrBadge size="md" tone="warning" radius="semi">md semi</GrBadge>
    <GrBadge size="md" tone="warning" radius="round">md round</GrBadge>

    <GrBadge size="lg" tone="info" radius="square">lg square</GrBadge>
    <GrBadge size="lg" tone="info" radius="semi">lg semi</GrBadge>
    <GrBadge size="lg" tone="info" radius="round">lg round</GrBadge>
  </div>
</template>

Бейдж статуса с иконкой перед подписью

Слот #icon ставит значок перед подписью: спиннер у идущей работы, галочку у результата, крестик у ошибки. Размер иконки идёт за size бейджа, поэтому ряд статусов не разъезжается.

Status Iconзависит от окружения витрины
<script setup lang="ts">
import IconCheck from '~icons/lucide/check'
import IconLoader from '~icons/lucide/loader-circle'
import IconX from '~icons/lucide/x'

import { GrBadge } from '@feugene/granularity'
</script>

<template>
  <div class="flex flex-col gap-6">
    <div class="flex flex-wrap items-center gap-2">
      <GrBadge tone="warning">
        <template #icon>
          <IconLoader class="h-full w-full animate-spin" />
        </template>
        На распознании
      </GrBadge>

      <GrBadge tone="success">
        <template #icon>
          <IconCheck class="h-full w-full" />
        </template>
        Распознано
      </GrBadge>

      <GrBadge tone="danger">
        <template #icon>
          <IconX class="h-full w-full" />
        </template>
        Ошибка распознавания
      </GrBadge>

      <GrBadge tone="neutral">
        Не распознан
      </GrBadge>
    </div>

    <div class="flex flex-wrap items-center gap-2">
      <GrBadge v-for="size in (['xs', 'sm', 'md', 'lg'] as const)" :key="size" :size tone="warning">
        <template #icon>
          <IconLoader class="h-full w-full animate-spin" />
        </template>
        {{ size }} — на распознании
      </GrBadge>
    </div>
  </div>
</template>

Спиннер — обычная иконка с animate-spin. GrProgressCircle в бейдж не кладут: его нижняя ступень равна 2rem и разносит плашку.

Бейджи внутри панелей действий

Компонент часто используется не сам по себе, а как secondary marker внутри toolbar/filter buttons. Этот сценарий показывает composition-паттерн.

Active filter: all

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

import { GrBadge, GrButton, GrButtonGroup, GrCard } from '@feugene/granularity'

const activeFilter = ref<'all' | 'failed' | 'review'>('all')
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
    <GrButtonGroup aria-label="Filter pipelines">
      <GrButton :variant="activeFilter === 'all' ? 'primary' : 'outline'" @click="activeFilter = 'all'">
        All
      </GrButton>
      <GrButton :variant="activeFilter === 'failed' ? 'primary' : 'outline'" @click="activeFilter = 'failed'">
        Failed
        <GrBadge class="ml-2" size="sm" radius="semi" dark tone="azure">3</GrBadge>
      </GrButton>
      <GrButton :variant="activeFilter === 'review' ? 'primary' : 'outline'" @click="activeFilter = 'review'">
        Needs review
        <GrBadge class="ml-2" size="sm" tone="warning" radius="semi">7</GrBadge>
      </GrButton>
    </GrButtonGroup>

    <GrCard class="p-4 text-sm text-[var(--gr-muted-fg)]">
      Active filter: <span class="font-semibold text-[var(--gr-fg)]">{{ activeFilter }}</span>
    </GrCard>
  </div>
</template>

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