GrDelta

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

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

Когда брать

  • число со знаком стоит в предложении — в описании операции, в ячейке таблицы, в подписи под графиком;
  • знак решает цвет — рост, падение и «не изменилось» видно до чтения самой величины;
  • рост бывает плохимpolarity="negative-good" для себестоимости, времени отклика и оттока;
  • величина может отсутствовать — «нет данных» и «ноль» рисуются по-разному.

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

НужноБерите
Крупный показатель плиткой, с подписью и динамикойGrStatistic
Число как статус или меткаGrBadge
Доля от целого полосой или кольцомGrProgressBar / GrProgressCircle
Ряд значений во времениGrSparkline
Пара «характеристика → значение»GrDescriptionList

Ноль нейтрален, `null` — это не ноль

Два состояния, которые двумя цветами не выражаются, и оба уже приводили к ошибкам в приложениях:

  • ноль — «не изменилось». Ни успех, ни ошибка: тон нейтральный при любой polarity. Условие вида value < 0 ? danger : success красит нулевое движение зелёным, как поступление;
  • null — величины нет вовсе. Печатается прочерком (emptyText), без тона, без стрелки и без приписок: $— читается как «ноль долларов».

Выбор тона живёт отдельной чистой функцией и покрыт тестами без монтирования — именно этот switch расходился копиями по приложению.

Функция публичная: deltaTone(value, polarity) и deltaDirection(value) отдаются из пакета. Они нужны там, где разметка дельты не подходит, а правило то же — tone у GrStatistic и GrBadge в плитках отчёта. Переписанное в приложении, оно расходится с этим при первой же правке — что и произошло с нулём, покрашенным в зелёный как поступление.

Полярность инвертирует тон, но не знак

<GrDelta :value="-15" suffix="%" polarity="negative-good" />

Себестоимость упала на 15 % — это успех, и величина зелёная. Но она всё ещё −15 %: знак принадлежит числу, а не оценке. Стрелка тоже идёт за знаком — вниз, потому что величина уменьшилась.

polarity="none" снимает тон совсем: у сальдо и смещения знак ничего не говорит о качестве.

Знак ставит `Intl`, а не склейка строк

showSign включает signDisplay: 'exceptZero' в форматировании, а не дописывает '+' к готовой строке. Разница не стилистическая: склейка уже ломала GrStatistic — строка '+1,234' перестаёт быть числом, и разряды теряются на следующем шаге.

Локаль берётся из i18n-адаптера, если не задана пропом.

Знак стоит перед валютой

Вместе с prefix знак уезжает в отдельный узел перед припиской: +$0,0280, а не $+0,0280. Знак относится к величине целиком, а не к числу после символа валюты, и второй порядок не принят ни в одной типографской традиции.

Инвариант выше при этом не ослаблен: знак по-прежнему ставит Intl, компонент лишь вынимает его из formatToParts — строка не склеивается и разряды не пересобираются. Без prefix выносить некуда, и разметка остаётся прежней.

Настраивать положение знака нечем намеренно. Отдельная тонкость — RTL: там знаку предшествует невидимая метка направления, которой он и разворачивается относительно цифр; она уезжает вместе со знаком, потому что управляет именно им.

Сами приписки рисует GrValue — общий примитив с GrStatistic. Оттуда же их оформление и токены --gr-value-*: валюта слева набирается как число, единица справа приглушается. Валюта справа (+1 000 ₽) получается сменой дефолта суффикса, рецепт — на странице примитива.

Кегль приходит из строки

Ступень по умолчанию (md) font-size не задаёт вовсе: величина набирается кеглем той строки, в которой стоит. Внутри заголовка она крупная, в подписи под графиком мелкая, в ячейке таблицы — ровно как остальной текст ячейки, и всё это без единого пропа.

<h2 class="text-[length:var(--gr-text-3xl)]">
  Выручка за март <GrDelta :value="8.4" :precision="1" suffix="%" show-arrow />
</h2>

Края лестницы остаются явными и берут контрольную шкалу — xs 12 px, sm 13 px, lg 16 px. Они нужны в другом случае: когда величина стоит не в предложении, а в ряду с контролами, и обязана совпасть с ними, а не с окружающим текстом.

Стрелка задана в em и растёт вместе с числом — своей ступени у неё нет. Переопределяя кегль снаружи, переопределяйте пару: межстрочный компонент не трогает, и половинчатая правка оставит строку с чужим интервалом.

Стрелка декоративна

showArrow рисует направление, но помечает иконку aria-hidden: направление уже объявлено знаком, и дублировать его для скринридера незачем. По умолчанию стрелки нет — в строке текста она чаще шумит, чем помогает.

Границы

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

Playground 8

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

Код
<GrDelta />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
size"xs" | "sm" | "md" | "lg" | undefinedundefined
emptyTextstring | undefinedundefinedЧем печатать отсутствующую величину.
localestring | undefinedundefinedЛокаль форматирования. Не задана — локаль i18n-адаптера.
precisionnumber | undefinedundefinedЗнаков после запятой. Не задано — как отдаст локаль.
prefixstring | undefinedundefinedПриписка перед числом: валюта, единица.
suffixstring | undefinedundefinedПриписка после числа: `%`, `мс`.
polarityGrDeltaPolarity | undefinedundefinedЧто считать хорошим. Для выручки рост — успех, для себестоимости и времени отклика — наоборот; без этого компонент врал бы в половине случаев.
showSignboolean | undefinedundefinedСтавить `+` у положительных. Минус подставляет `Intl` сам.
showArrowboolean | undefinedundefinedСтрелка направления. Декоративна: направление уже в знаке.
valueобязательныйnumber | nullВеличина. `null` — её нет; это не ноль.

Примеры 3

Величина со знаком внутри предложения

Знак, тон и приписки для величины, стоящей в строке текста. Ноль нейтрален, null печатается прочерком без тона — «нет данных» и «ноль» это разные утверждения.

Balance change: +$1 284,50

Margin: -$12,50

Unchanged: $0,00

Not measured:

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

<template>
  <div class="grid gap-2 text-sm">
    <p>
      Balance change: <GrDelta :value="1284.5" :precision="2" prefix="$" />
    </p>
    <p>
      Margin: <GrDelta :value="-12.5" :precision="2" prefix="$" />
    </p>
    <!-- Ноль нейтрален: «не изменилось» — третье состояние, и двумя цветами
         его не выразить. -->
    <p>
      Unchanged: <GrDelta :value="0" :precision="2" prefix="$" />
    </p>
    <!-- `null` — величины нет вовсе. Это не ноль, поэтому ни тона, ни приписок. -->
    <p>
      Not measured: <GrDelta :value="null" prefix="$" />
    </p>
  </div>
</template>

Знак ставит Intl через signDisplay, а не склейка строк: '+' + value превращает число в строку и теряет разряды.

Полярность: когда рост — это плохо

Для выручки рост — успех, для оттока и времени отклика — наоборот. polarity инвертирует тон, оставляя знак и направление стрелки за самой величиной.

Revenue: +8,4% — polarity="positive-good"

Churn: +2,1% — polarity="negative-good"

Response time: -15,0% — polarity="negative-good"

Balance offset: -3,2% — polarity="none"

Polarity
<script setup lang="ts">
import { GrDelta } from '@feugene/granularity'

const rows = [
  { metric: 'Revenue', value: 8.4, polarity: 'positive-good' as const },
  { metric: 'Churn', value: 2.1, polarity: 'negative-good' as const },
  { metric: 'Response time', value: -15, polarity: 'negative-good' as const },
  { metric: 'Balance offset', value: -3.2, polarity: 'none' as const },
]
</script>

<template>
  <!--
    Полярность инвертирует тон, но не знак: «−15 %» времени отклика зелёное,
    потому что стало быстрее, — и всё ещё минус. Без этого компонент врал бы
    в половине случаев.
  -->
  <div class="grid gap-2 text-sm">
    <p v-for="row in rows" :key="row.metric">
      {{ row.metric }}:
      <GrDelta :value="row.value" :precision="1" suffix="%" :polarity="row.polarity" show-arrow />
      <!-- Не `opacity-*`: прозрачность разбавляет выверенный на AA токен и роняет контраст. -->
      <span class="text-[var(--gr-muted-fg)]">&#32;— polarity="{{ row.polarity }}"</span>
    </p>
  </div>
</template>

Величина набирается кеглем своей строки

Одна и та же разметка в заголовке, в подзаголовке и в подписи: size не задан нигде, кегль приходит от строки. Стрелка и суффикс растут вместе с числом.

Выручка за март +8,4%
Средний чек -3,2%
Возвраты +1,6%
В ряду с контролами:+8,4%

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

<template>
  <!--
    Разметка величины во всех трёх строках одна и та же — `size` не задан
    нигде. Кегль приходит от строки, поэтому стрелка и суффикс растут вместе
    с числом, а не остаются 14-пиксельными внутри заголовка.
  -->
  <div class="grid gap-4">
    <div class="text-[length:var(--gr-text-3xl)] leading-[var(--gr-leading-3xl)] font-600">
      Выручка за март
      <GrDelta :value="8.4" :precision="1" suffix="%" show-arrow />
    </div>

    <div class="text-[length:var(--gr-text-xl)] leading-[var(--gr-leading-xl)]">
      Средний чек
      <GrDelta :value="-3.2" :precision="1" suffix="%" show-arrow />
    </div>

    <div class="text-[length:var(--gr-control-text-sm)]">
      Возвраты
      <GrDelta :value="1.6" :precision="1" suffix="%" polarity="negative-good" show-arrow />
    </div>

    <!--
      Явная ступень нужна там, где величина стоит не в предложении, а в ряду
      с контролами: тогда она обязана совпасть с ними, а не с текстом вокруг.
    -->
    <div class="flex items-center gap-2 text-[length:var(--gr-text-xl)]">
      <span>В ряду с контролами:</span>
      <GrDelta :value="8.4" :precision="1" suffix="%" size="sm" />
    </div>
  </div>
</template>

Явная ступень (size="sm") нужна в обратном случае — когда величина стоит не в предложении, а в ряду с контролами и обязана совпасть с ними, а не с текстом вокруг.

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