GrDelta

Package: @feugene/granularitycoreGroup: data

Shows a signed value with its sign, tone and arrow inline in a sentence.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • a signed number stands inside a sentence — in the description of an operation, in a table cell, in the caption under a chart;
  • the sign decides the colour — growth, decline and “unchanged” are seen before the quantity itself is read;
  • growth can be badpolarity="negative-good" for cost, response time and churn;
  • the quantity may be missing — “no data” and “zero” are drawn differently.

When to take something else

NeedTake
A large metric as a tile, with a label and a trendGrStatistic
A number as a status or a labelGrBadge
A share of a whole as a bar or a ringGrProgressBar / GrProgressCircle
A series of values over timeGrSparkline
A “property → value” pairGrDescriptionList

Zero is neutral, `null` is not zero

Two states that are not expressed by two colours, and both have already led to bugs in applications:

  • zero — “unchanged”. Neither a success nor an error: the tone is neutral with any polarity. A condition of the form value < 0 ? danger : success paints a zero movement green, like an income;
  • null — there is no quantity at all. It is printed as a dash (emptyText), with no tone, no arrow and no affixes: $— reads as “zero dollars”.

The choice of tone lives in a pure function of its own and is covered by tests without mounting — it is exactly that switch that used to spread through an application in copies.

The function is public: deltaTone(value, polarity) and deltaDirection(value) are given away by the package. They are needed where the markup of a delta does not fit but the rule is the same — the tone of GrStatistic and GrBadge in the tiles of a report. Rewritten in an application, it diverges from this one at the very first edit — which is what happened with the zero painted green like an income.

Polarity inverts the tone but not the sign

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

Cost fell by 15 % — that is a success, and the quantity is green. But it is still −15 %: the sign belongs to the number, not to the judgement. The arrow follows the sign as well — downwards, because the quantity decreased.

polarity="none" removes the tone entirely: for a balance or an offset the sign says nothing about quality.

The sign is set by `Intl`, not by string concatenation

showSign switches on signDisplay: 'exceptZero' in the formatting rather than appending a '+' to a ready string. The difference is not stylistic: concatenation has already broken GrStatistic — the string '+1,234' stops being a number, and the digit groups are lost at the next step.

The locale comes from the i18n adapter unless it is set with a prop.

The sign stands before the currency

Together with prefix the sign moves into a node of its own before the affix: +$0,0280, not $+0,0280. The sign belongs to the quantity as a whole rather than to the number after the currency symbol, and the second order is accepted in no typographic tradition.

The invariant above is not weakened in the process: the sign is still set by Intl, the component merely takes it out of formatToParts — the string is not concatenated and the digit groups are not reassembled. Without prefix there is nowhere to take it out to, and the markup stays as it was.

There is deliberately no way to configure the position of the sign. A separate subtlety is RTL: there the sign is preceded by an invisible direction mark, which is what turns it around relative to the digits; it moves together with the sign, because it is the sign it governs.

The affixes themselves are drawn by GrValue — the primitive shared with GrStatistic. Their styling and the --gr-value-* tokens come from there too: a currency on the left is set like a number, a unit on the right is dimmed. A currency on the right (+1 000 ₽) is obtained by changing the default of the suffix, and the recipe is on the page of the primitive.

The type size comes from the line

The default step (md) does not set font-size at all: the quantity is set in the type size of the line it stands in. Inside a heading it is large, in a caption under a chart it is small, in a table cell it is exactly like the rest of the text of the cell — and all of that without a single prop.

<h2 class="text-[length:var(--gr-text-3xl)]">
  Revenue for March <GrDelta :value="8.4" :precision="1" suffix="%" show-arrow />
</h2>

The edges of the ladder stay explicit and take the control scale — xs 12 px, sm 13 px, lg 16 px. They are needed in the other case: when the quantity stands not inside a sentence but in a row with controls, and has to match them rather than the surrounding text.

The arrow is set in em and grows together with the number — it has no step of its own. When overriding the type size from the outside, override the pair: the component does not touch the line height, and a half-measure leaves the line with someone else’s leading.

The arrow is decorative

showArrow draws the direction but marks the icon aria-hidden: the direction is already announced by the sign, and there is no point duplicating it for a screen reader. There is no arrow by default — in a line of text it more often adds noise than helps.

Limits

The component does not compute the delta: it accepts a ready one. “Compared to what” — the period, the base value, the filters — is known by the application, and dragging that into a presentational component means dragging its data model in there too. Percentages, comparisons of periods and a sparkline are outside as well.

Playground 8

Loading…

Code
<GrDelta />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
size"xs" | "sm" | "md" | "lg" | undefinedundefined
emptyTextstring | undefinedundefinedWhat to print a missing quantity with.
localestring | undefinedundefinedThe locale of the formatting. Unset — the locale of the i18n adapter.
precisionnumber | undefinedundefinedDigits after the decimal point. Unset — as the locale gives it.
prefixstring | undefinedundefinedAn affix before the number: a currency, a unit.
suffixstring | undefinedundefinedAn affix after the number: `%`, `ms`.
polarityGrDeltaPolarity | undefinedundefinedWhat counts as good. For revenue growth is a success, for cost and response time it is the opposite; without this the component would lie in half of the cases.
showSignboolean | undefinedundefinedPut a `+` on positive values. The minus is set by `Intl` itself.
showArrowboolean | undefinedundefinedAn arrow of direction. Decorative: the direction is already in the sign.
valuerequirednumber | nullThe quantity. `null` means there is none; that is not a zero.

Examples 3

Signed value inside a sentence

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>

Polarity: when growth is bad

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>

The value takes the type size of its line

Выручка за март +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>

Component documentationAll components