GrStatistic

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

Берут, когда один показатель — главный.

Когда брать

  • один показатель — главный — выручка, число заказов, конверсия: крупная цифра читается первой;
  • рядом нужна динамикаtrend со стрелкой и подписью «+12 % к прошлой неделе»;
  • число длинное — группировка разрядов и precision по локали, без ручного форматирования;
  • значение меняется на глазахanimate доводит цифру до нового значения, а не подменяет её;
  • плитка кликабельнаhref/clickable ведут в раздел с подробностями.

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

НужноБерите
Показателей несколько и их сравниваютGrChartBar
Важен ход во времениGrChartLine / GrSparkline
Показана доля от целогоGrProgressCircle
Значение — статус, а не числоGrBadge
Величина стоит внутри строки текста, а не плиткойGrDelta

Компонент презентационный: данные не грузит и дельту не считает — только форматирует и подаёт. Тренд приходит готовым, потому что «к чему сравнивать» знает приложение, а не плитка.

Форматирование по локали

<GrStatistic title="Выручка" :value="1234567.5" :precision="2" prefix="₽" />

Разделители берутся из локали через Intl.NumberFormat, а локаль — из i18n-адаптера пакета. Ничего настраивать не нужно: ru даст 1 234 567,50, de-DE1.234.567,50.

Порядок разрешения: groupSeparator/decimalSeparator → проп locale → локаль адаптера → встроенный дефолт (узкий пробел и точка). Явный разделитель подменяет только свою часть, остальные правила локали — группировку по три или по индийской схеме, знак минуса — компонент сохраняет.

Нечисловое значение выводится как есть: «2 ч 15 мин», «—» не требуют обходных пропов. Форматирование живёт отдельной чистой функцией formatStatisticValue и доступно из пакета.

Не доехавший обязательный value рисуется прочерком — тем же знаком пустоты, которым GrDelta показывает отсутствующую величину, — и объясняется предупреждением в dev-режиме. Плитка со словом undefined выглядит как данные, а не как ошибка, поэтому такой вариант исключён.

Динамика

<GrStatistic :value="482" trend="up" trend-text="+12,5 % к прошлой неделе" />

trend задаёт цвет и иконку строки, trendText — сам текст. Иконки направления встроенные; проп icon слева от блока принимает Vue-компонент или класс иконки вашей сборки (см. «Иконки»). Направление дополнительно объявляется скрытой подписью («Рост», «Падение», «Без изменений»): иконка декоративна, а «+12,5 %» без контекста рост от падения не отличает — цвет диктору тоже не виден. Подпись остаётся и при своём слоте #trend.

Приписки рисует `GrValue`

<GrStatistic title="Средний чек" :value="14.99" :precision="2" prefix="$" suffix="за заказ" />

Запись величины — префикс, число, суффикс — плитка не рисует сама: этим занят примитив GrValue, общий у неё с GrDelta. Оттуда же и дефолты: слева приписка набирается как число ($14,99), справа — приглушённой и мельче (42 %).

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

Чем является приписка — валютой, единицей измерения или пометкой — не решает никто: оформление обеих настраивается токенами --gr-value-*. Так получается и валюта справа (1 284 500 ₽), которой дефолт суффикса не подходит, — рецепт на странице примитива.

Тон по знаку и строка динамики — разные сигналы

<GrStatistic title="Маржа" :value="-1240" prefix="₽" polarity="positive-good" />

polarity красит значение по знаку самой величины: positive-good для выручки, negative-good для себестоимости и оттока, none — знак ничего не говорит о качестве. Ноль нейтрален при любой полярности, а нечисловое значение («2 ч 15 мин», «—») знака не имеет и тона не получает.

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

Явный tone сильнее polarity: выведенный тон — умолчание, а не диктат. Тот же выбор тона доступен отдельной функцией deltaTone — см. GrDelta, где он и живёт.

Анимация счётчика

<GrStatistic title="Выручка" :value="revenue" animate :animate-duration="900" />

animate перебирает числа при появлении плитки (от нуля) и при каждой смене значения — от прежнего числа, а не от нуля: перебор с нуля на каждом обновлении дашборда читался бы как сброс данных.

Длительность задаёт animateDuration в миллисекундах (по умолчанию 600). Токеном она не задаётся намеренно: шкала --gr-duration-* заканчивается на 300 мс и описывает смену состояния — цвет, рамку, появление слоя. Перебор чисел — другой жанр, и его число живёт там же, где твин, — в JS.

Перебираются только числа: «2 ч 15 мин» и «—» ставятся сразу. Ширина строки не дёргается — значение набрано tabular-nums.

prefers-reduced-motion компонент читает сам. Глобальный кламп в base.css гасит CSS-анимации и переходы, но не JS-твин — см. ../motion.md. Под reduce значение ставится мгновенно, без единого промежуточного кадра.

Диктор слышит конечное значение. Пока идёт перебор, видимое число помечено aria-hidden, а рядом живёт визуально скрытый узел с итогом: «1 284 500» на экране и «743 210» в ушах — не шум, а неверные данные.

Переход к деталям

<GrStatistic title="Заказы" :value="1284" href="/orders" />

<GrStatistic title="Заказы" :value="1284" clickable @click="drill" />

<GrStatistic title="Заказы" :value="1284" :as="RouterLink" :to="{ name: 'orders' }" />

Тот же приём, что у GrCard и GrListItem: href даёт ссылку, clickable — кнопку, as — свой тег или компонент роутера (сильнее href). Интерактивная плитка получает курсор, подсветку и фокус-кольцо; своей заливки у неё нет — показатель обычно уже лежит в карточке, и вторая поверхность спорила бы с ней.

Отдельного ariaLabel нет: доступное имя собирается из содержимого, а подпись показателя и есть его имя.

Загрузка

loading подменяет значение скелетоном той же высоты, чтобы блок не прыгал; область помечена role="status" и содержит скрытый текст загрузки.

Оформление

ТочкаЧто задаёт
size (xslg)лестницы кеглей подписи, значения, аффиксов и динамики; читается из GrConfigProvider
toneцвет значения; тона берутся из -text-токенов — насыщенный тон как текст не проходит по контрасту
polarityвыводит tone из знака самого значения; читается из GrConfigProvider
--gr-statistic-value-colorцвет значения точечно, сильнее tone
--gr-statistic-title-colorцвет подписи

Слоты #icon, #title, #prefix, #suffix, #trend и слот по умолчанию (вместо форматированного значения) заменяют соответствующие части разметкой.

Разметка: подпись и значение — пара

Подпись и значение выводятся как <dl><dt><dd>: это пара «термин — значение», а не два соседних блока, которые диктор просто читает подряд. <dl> появляется только вместе с подписью — список определений без <dt> был бы той же бессвязностью, только с претензией на семантику.

Строка динамики остаётся снаружи <dl>: внутри списка определений разрешены только группы dt/dd. Место в DOM у неё прежнее, вёрстка не меняется. Поля <dl> и <dd> обнулены классом — preflight пакета сбрасывает их только у body.

Все data-атрибуты (data-gr-statistic-title, data-gr-statistic-value, …) сохранены: стили потребителя, написанные по ним, продолжают работать.

Playground 15

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

Код
<GrStatistic />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefinedТон значения; точечно перекрывается `--gr-statistic-value-color`.
titlestring | undefinedundefinedПодпись над значением.
iconstring | Component | undefinedundefinedИконка слева от блока: Vue-компонент либо класс иконки вашей UnoCSS-сборки (`'i-lucide-users'` — тогда нужен ваш `presetIcons`, см. `docs/installation.md`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
loadingboolean | undefinedfalseСостояние загрузки: вместо значения — плейсхолдер.
animateboolean | undefinedfalseПеребирать числа при появлении и при смене значения. Нечисловое значение ставится сразу, под `prefers-reduced-motion: reduce` — тоже.
asstring | Component | undefinedundefinedСвой корневой тег (`RouterLink`, `Link` от Inertia). Сильнее `href`.
hrefstring | undefinedundefinedПоказатель-ссылка: переход к деталям.
clickableboolean | undefinedfalseПоказатель-кнопка: интерактивна вся плитка.
localestring | undefinedundefinedBCP-47 локаль форматирования. Не задана — берётся из i18n-адаптера; нет и адаптера — работают ручные разделители.
precisionnumber | undefinedundefinedЧисло знаков после запятой.
prefixstring | undefinedundefinedПриписка перед значением (валюта, знак).
suffixstring | undefinedundefinedПриписка после значения (единица измерения, `%`).
polarityGrDeltaPolarity | undefinedundefinedЧто считать хорошим: тон выводится из знака самого значения. Для выручки рост — успех, для себестоимости и оттока — наоборот. Явный `tone` сильнее: выведенный тон — умолчание, а не диктат.
decimalSeparatorstring | undefinedundefinedДесятичный разделитель. Сильнее локали; без него и без локали — точка.
groupSeparatorstring | undefinedundefinedРазделитель разрядов. Сильнее локали; без него и без локали — узкий пробел.
trendGrStatisticTrend | undefinedundefinedНаправление динамики — задаёт цвет и иконку строки под значением.
trendTextstring | undefinedundefinedТекст динамики (например `+12,5% к прошлой неделе`).
animateDurationnumber | undefined600Длительность перебора в миллисекундах. Не токеном: шкала `--gr-duration-*` заканчивается на 300 мс и описывает смену состояния, а перебор чисел — другой жанр, и его число живёт там же, где твин.
valueобязательныйstring | numberЗначение показателя. Нечисловая строка выводится как есть.

Slots

SlotTypeОписание
defaultanyЗначение вместо пропа `value`.
iconanyИконка перед подписью.
titleanyПодпись показателя вместо пропа `title`.
prefixanyПриписка перед значением: знак валюты, стрелка.
suffixanyПриписка после значения: единица измерения, процент.
trendanyДинамика под значением вместо встроенного `GrDelta`.

Events

EventTypeОписание
click[event: MouseEvent]

Примеры 5

Ряд показателей

Показатель с подписью, иконкой, приписками и форматированием: precision фиксирует знаки, разряды разделяются автоматически.

Revenue
$1 284 500
Active users
18 342
Conversion
4,8%

Basic
<script setup lang="ts">
import { GrCard, GrStatistic } from '@feugene/granularity'
</script>

<template>
  <div class="grid gap-4 sm:grid-cols-3">
    <GrCard class="p-4">
      <GrStatistic
        title="Revenue"
        :value="1284500"
        :precision="0"
        prefix="$"
        icon="i-lucide-wallet"
      />
    </GrCard>

    <GrCard class="p-4">
      <GrStatistic
        title="Active users"
        :value="18342"
        icon="i-lucide-users"
      />
    </GrCard>

    <GrCard class="p-4">
      <GrStatistic
        title="Conversion"
        :value="4.8"
        :precision="1"
        suffix="%"
        icon="i-lucide-target"
      />
    </GrCard>
  </div>
</template>

Плитки со счётчиком, ведущие в раздел

animate перебирает числа при появлении плитки и при каждой смене значения — от прежнего числа, а не от нуля: перебор «с нуля» на обновлении дашборда читался бы как сброс данных. Длительность задаёт animateDuration в миллисекундах. Переход к деталям — тем же приёмом, что у GrCard и GrListItem: href даёт ссылку, clickable — кнопку, as — свой тег.

Conversion
4,8%
Revenue is a link, active users is a button (opened 0 times), conversion counts for 900 ms. Turn on "reduce motion" in the OS and the numbers stop counting.

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

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

const revenue = ref(1284500)
const users = ref(18342)
const conversion = ref(4.8)
const opened = ref(0)

/** Обновление дашборда: перебор идёт от прежнего числа, а не от нуля. */
function refresh() {
  revenue.value = Math.round(900000 + Math.random() * 700000)
  users.value = Math.round(12000 + Math.random() * 12000)
  conversion.value = Number((3 + Math.random() * 4).toFixed(1))
}
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-4 sm:grid-cols-3">
      <GrCard class="p-4">
        <GrStatistic
          title="Revenue"
          :value="revenue"
          :precision="0"
          prefix="$"
          icon="i-lucide-wallet"
          animate
          href="#gr-statistic"
          trend="up"
          trend-text="+12.5% vs last week"
        />
      </GrCard>

      <GrCard class="p-4">
        <GrStatistic
          title="Active users"
          :value="users"
          icon="i-lucide-users"
          animate
          clickable
          @click="opened++"
        />
      </GrCard>

      <GrCard class="p-4">
        <GrStatistic
          title="Conversion"
          :value="conversion"
          :precision="1"
          suffix="%"
          icon="i-lucide-target"
          animate
          :animate-duration="900"
        />
      </GrCard>
    </div>

    <div class="flex flex-wrap items-center gap-3">
      <GrButton size="sm" variant="secondary" @click="refresh">
        Refresh data
      </GrButton>

      <span class="text-sm text-[var(--gr-muted-fg)]">
        Revenue is a link, active users is a button (opened {{ opened }} times), conversion counts for 900 ms.
        Turn on "reduce motion" in the OS and the numbers stop counting.
      </span>
    </div>
  </div>
</template>

Перебор ведёт JS, поэтому глобальный кламп движения его не покрывает — компонент сам читает prefers-reduced-motion и под reduce ставит значение сразу. Пока перебор идёт, диктору отдаётся конечное значение: «1 284 500» на экране и «743 210» в ушах — не шум, а неверные данные.

Динамика и загрузка

trend + trend-text добавляют строку динамики со стрелкой и цветом, loading подменяет значение плейсхолдером той же высоты — блок не прыгает.

Orders
2 148
Рост+12.5% week over week
Refunds
97
Падение-3.1% week over week
Average check
5 980,40
Без измененийNo change

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

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

const loading = ref(false)

function refresh(): void {
  loading.value = true
  setTimeout(() => {
    loading.value = false
  }, 1200)
}
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-4 sm:grid-cols-3">
      <GrCard class="p-4">
        <GrStatistic
          title="Orders"
          :value="2148"
          tone="success"
          trend="up"
          trend-text="+12.5% week over week"
          :loading="loading"
        />
      </GrCard>

      <GrCard class="p-4">
        <GrStatistic
          title="Refunds"
          :value="97"
          tone="danger"
          trend="down"
          trend-text="-3.1% week over week"
          :loading="loading"
        />
      </GrCard>

      <GrCard class="p-4">
        <GrStatistic
          title="Average check"
          :value="5980.4"
          :precision="2"
          suffix=""
          trend="flat"
          trend-text="No change"
          :loading="loading"
        />
      </GrCard>
    </div>

    <div>
      <GrButton size="sm" @click="refresh">
        Refresh data
      </GrButton>
    </div>
  </div>
</template>

Плейсхолдер помечен role="status" и aria-busy, поэтому обновление данных не остаётся незамеченным.

Тон по знаку значения

polarity выводит тон из знака самой величины: positive-good для выручки, negative-good для себестоимости и оттока. Ноль нейтрален при любой полярности — «не изменилось» третье состояние, и двумя цветами оно не выражается. Явный tone сильнее: выведенный тон — умолчание, а не диктат.

Margin
-1 240
Cost of goods
-1 240
Balance
-1 240

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

import { GrCard, GrSegmented, GrStatistic } from '@feugene/granularity'

const margin = ref(-1240)

const presets = [
  { value: 4820, label: 'Profit' },
  { value: 0, label: 'Break even' },
  { value: -1240, label: 'Loss' },
]
</script>

<template>
  <div class="grid gap-4">
    <GrSegmented
      v-model="margin"
      :options="presets"
      size="sm"
    />

    <div class="grid gap-4 sm:grid-cols-3">
      <GrCard class="p-4">
        <!--
          Тон выводится из знака самой величины: полярность говорит, что здесь
          считать хорошим, а не какой краской красить.
        -->
        <GrStatistic
          title="Margin"
          :value="margin"
          prefix=""
          polarity="positive-good"
        />
      </GrCard>

      <GrCard class="p-4">
        <!-- У себестоимости рост — проблема, и тон обязан быть зеркальным. -->
        <GrStatistic
          title="Cost of goods"
          :value="margin"
          prefix=""
          polarity="negative-good"
        />
      </GrCard>

      <GrCard class="p-4">
        <!-- Явный `tone` сильнее: выведенный тон — умолчание, а не диктат. -->
        <GrStatistic
          title="Balance"
          :value="margin"
          prefix=""
          polarity="positive-good"
          tone="neutral"
        />
      </GrCard>
    </div>
  </div>
</template>

polarity красит значение, trend — строку под ним. Это независимые сигналы: показатель может краснеть без подписи о динамике, а подпись — стоять под нейтральным значением.

Слоты и нечисловые значения

Слоты #icon, #trend, #prefix/#suffix подставляют любой контент, а нечисловое значение («2 h 15 min») выводится как есть.

Uptime
99,982%
SLA met
Time to first response
2 h 15 min
Target — under 4 hours

Slots
<script setup lang="ts">
import { GrBadge, GrCard, GrStatistic } from '@feugene/granularity'

const uptime = '99.982'
</script>

<template>
  <div class="grid gap-4 sm:grid-cols-2">
    <GrCard class="p-4">
      <GrStatistic title="Uptime" :value="uptime" :precision="3" suffix="%" size="lg" tone="success">
        <template #trend>
          <GrBadge tone="success" size="xs">
            SLA met
          </GrBadge>
        </template>
      </GrStatistic>
    </GrCard>

    <GrCard class="p-4">
      <GrStatistic title="Time to first response" value="2 h 15 min" size="sm">
        <template #icon>
          <span class="i-lucide-clock block h-4 w-4" aria-hidden="true" />
        </template>
        <template #trend>
          <span>Target — under 4 hours</span>
        </template>
      </GrStatistic>
    </GrCard>
  </div>
</template>

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