GrStatistic
Берут, когда один показатель — главный.
Когда брать
- один показатель — главный — выручка, число заказов, конверсия: крупная цифра читается первой;
- рядом нужна динамика —
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-DE — 1.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 (xs…lg) | лестницы кеглей подписи, значения, аффиксов и динамики; читается из 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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
tone | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefined | undefined | Тон значения; точечно перекрывается `--gr-statistic-value-color`. |
title | string | undefined | undefined | Подпись над значением. |
icon | string | Component | undefined | undefined | Иконка слева от блока: Vue-компонент либо класс иконки вашей UnoCSS-сборки (`'i-lucide-users'` — тогда нужен ваш `presetIcons`, см. `docs/installation.md`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
loading | boolean | undefined | false | Состояние загрузки: вместо значения — плейсхолдер. |
animate | boolean | undefined | false | Перебирать числа при появлении и при смене значения. Нечисловое значение ставится сразу, под `prefers-reduced-motion: reduce` — тоже. |
as | string | Component | undefined | undefined | Свой корневой тег (`RouterLink`, `Link` от Inertia). Сильнее `href`. |
href | string | undefined | undefined | Показатель-ссылка: переход к деталям. |
clickable | boolean | undefined | false | Показатель-кнопка: интерактивна вся плитка. |
locale | string | undefined | undefined | BCP-47 локаль форматирования. Не задана — берётся из i18n-адаптера; нет и адаптера — работают ручные разделители. |
precision | number | undefined | undefined | Число знаков после запятой. |
prefix | string | undefined | undefined | Приписка перед значением (валюта, знак). |
suffix | string | undefined | undefined | Приписка после значения (единица измерения, `%`). |
polarity | GrDeltaPolarity | undefined | undefined | Что считать хорошим: тон выводится из знака самого значения. Для выручки рост — успех, для себестоимости и оттока — наоборот. Явный `tone` сильнее: выведенный тон — умолчание, а не диктат. |
decimalSeparator | string | undefined | undefined | Десятичный разделитель. Сильнее локали; без него и без локали — точка. |
groupSeparator | string | undefined | undefined | Разделитель разрядов. Сильнее локали; без него и без локали — узкий пробел. |
trend | GrStatisticTrend | undefined | undefined | Направление динамики — задаёт цвет и иконку строки под значением. |
trendText | string | undefined | undefined | Текст динамики (например `+12,5% к прошлой неделе`). |
animateDuration | number | undefined | 600 | Длительность перебора в миллисекундах. Не токеном: шкала `--gr-duration-*` заканчивается на 300 мс и описывает смену состояния, а перебор чисел — другой жанр, и его число живёт там же, где твин. |
valueобязательный | string | number | — | Значение показателя. Нечисловая строка выводится как есть. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Значение вместо пропа `value`. |
icon | any | Иконка перед подписью. |
title | any | Подпись показателя вместо пропа `title`. |
prefix | any | Приписка перед значением: знак валюты, стрелка. |
suffix | any | Приписка после значения: единица измерения, процент. |
trend | any | Динамика под значением вместо встроенного `GrDelta`. |
Events
| Event | Type | Описание |
|---|---|---|
click | [event: MouseEvent] | — |
Примеры 5
Ряд показателей
Показатель с подписью, иконкой, приписками и форматированием: precision фиксирует знаки, разряды разделяются автоматически.
- Revenue
- $1 284 500
- Active users
- 18 342
- Conversion
- 4,8%
<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%
<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
- Refunds
- 97
- Average check
- 5 980,40₽
<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
<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%
- Time to first response
- 2 h 15 min
<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>