@apliteni/apliteni-ui 0.82.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,7 +56,7 @@
56
56
 
57
57
  **Rule:** Keep units smaller and in body ink.
58
58
 
59
- **Do:** Keep units smaller and in body ink, and explain abbreviations in reachable text.
59
+ **Do:** Keep units smaller and in body ink, and explain abbreviations in reachable text. A short value in a stacked row carries its own unit or count word, because the header that named it is no longer on screen.
60
60
 
61
61
  **Don't:** Hide units in muted text or make readers guess what an abbreviation means.
62
62
 
@@ -72,6 +72,8 @@
72
72
 
73
73
  **Don't:** Squeeze columns until values are unreadable or silently remove data.
74
74
 
75
+ **Except:** On a phone, a row whose long column is a paragraph stacks instead of scrolling: the short values on one line, the paragraph under them, and the header kept for screen readers. No column is dropped.
76
+
75
77
  ## Keep updates stable
76
78
 
77
79
  <!-- rule: states -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.82.0",
3
+ "version": "0.84.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -97,11 +97,14 @@ interface StatDelta {
97
97
  basis?: string;
98
98
  tooltip?: string;
99
99
  direction?: 'up' | 'down' | 'flat';
100
- none?: string;
101
100
  }
102
101
  interface StatFigure {
103
102
  label: string;
104
103
  value: ReactNode;
104
+ /** Context that is not a change, such as "of revenue": no arrow, no tone. It
105
+ * takes the row a change would take, and leads that row beside one. A short
106
+ * phrase: the row is one line, and a longer caption is clipped. */
107
+ caption?: string;
105
108
  delta?: StatDelta;
106
109
  trend?: ReactNode;
107
110
  }
@@ -379,17 +379,21 @@ var directionOf = (text2) => {
379
379
  return /^[-−–(]/.test(t) ? "down" : /^\+/.test(t) ? "up" : "flat";
380
380
  };
381
381
  var hasChange = (d) => !!d && d.value != null && d.value !== "";
382
- function Delta({ delta, basisId }) {
383
- if (!hasChange(delta)) {
384
- return /* @__PURE__ */ jsx11("dd", { className: "ui-stat__delta ui-stat__delta--none", children: delta.none || "No earlier figure" });
385
- }
382
+ function ContextRow({ caption, delta, basisId }) {
383
+ if (!hasChange(delta)) return caption ? /* @__PURE__ */ jsx11("dd", { className: "ui-stat__caption", children: caption }) : null;
384
+ const lead = caption ? /* @__PURE__ */ jsxs7(Fragment, { children: [
385
+ /* @__PURE__ */ jsx11("span", { className: "ui-stat__caption", children: caption }),
386
+ " "
387
+ ] }) : null;
386
388
  const dir = delta.direction && GLYPH[delta.direction] ? delta.direction : directionOf(delta.value);
387
- return /* @__PURE__ */ jsxs7("dd", { className: "ui-stat__delta", "aria-describedby": !delta.basis && basisId ? basisId : void 0, children: [
389
+ const own = delta.basis || "";
390
+ return /* @__PURE__ */ jsxs7("dd", { className: "ui-stat__delta", "aria-describedby": !own && basisId ? basisId : void 0, children: [
391
+ lead,
388
392
  /* @__PURE__ */ jsx11(Icon, { name: GLYPH[dir] }),
389
393
  delta.tooltip ? /* @__PURE__ */ jsx11(Tooltip, { text: delta.tooltip, children: /* @__PURE__ */ jsx11("span", { className: "ui-stat__change", children: delta.value }) }) : /* @__PURE__ */ jsx11("span", { className: "ui-stat__change", children: delta.value }),
390
- delta.basis ? /* @__PURE__ */ jsxs7(Fragment, { children: [
394
+ own ? /* @__PURE__ */ jsxs7(Fragment, { children: [
391
395
  " ",
392
- /* @__PURE__ */ jsx11("span", { className: "ui-stat__basis", children: delta.basis })
396
+ /* @__PURE__ */ jsx11("span", { className: "ui-stat__basis", children: own })
393
397
  ] }) : null
394
398
  ] });
395
399
  }
@@ -406,7 +410,7 @@ function StatBand({ stats, variant = "tiles", basis, basisId: sharedBasisId, lab
406
410
  return /* @__PURE__ */ jsxs7("div", { className: cls, children: [
407
411
  /* @__PURE__ */ jsx11("dt", { className: "ui-stat__label", children: s.label }),
408
412
  /* @__PURE__ */ jsx11("dd", { className: "ui-stat__value", children: s.value }),
409
- s.delta ? /* @__PURE__ */ jsx11(Delta, { delta: s.delta, basisId }) : null,
413
+ /* @__PURE__ */ jsx11(ContextRow, { caption: s.caption, delta: s.delta, basisId }),
410
414
  s.trend ? /* @__PURE__ */ jsx11("dd", { className: "ui-stat__trend", children: s.trend }) : null
411
415
  ] }, `${s.label}-${i}`);
412
416
  }) })
@@ -23,23 +23,26 @@ const directionOf = (text) => {
23
23
  const GLYPH = { up: 'arrowUp', down: 'arrowDown', flat: 'minus' };
24
24
  const hasChange = (delta) => delta && delta.value != null && delta.value !== '';
25
25
 
26
- const deltaHtml = (delta, basisId) => {
27
- if (!delta) return '';
28
- if (!hasChange(delta)) {
29
- return `<dd class="ui-stat__delta ui-stat__delta--none">${esc(delta.none || 'No earlier figure')}</dd>`;
30
- }
26
+ // One row and one line: the caption, the change, then what it is measured
27
+ // against. Nothing to compare draws no row. why: docs/specification.md#stat-bands
28
+ const contextRow = (caption, delta, basisId) => {
29
+ if (!hasChange(delta)) return caption ? `<dd class="ui-stat__caption">${esc(caption)}</dd>` : '';
30
+ // The space is read where the CSS gap is only drawn: "of income+1.2 pts" else.
31
+ const lead = caption ? `<span class="ui-stat__caption">${esc(caption)}</span> ` : '';
31
32
  const text = String(delta.value);
32
33
  const dir = GLYPH[delta.direction] ? delta.direction : directionOf(text);
34
+ // Never dropped: a basis is passed when the band's caption does not cover this figure.
33
35
  const own = delta.basis ? ` <span class="ui-stat__basis">${esc(delta.basis)}</span>` : '';
34
- const describedby = !delta.basis && basisId ? ` aria-describedby="${basisId}"` : '';
35
- return `<dd class="ui-stat__delta"${describedby}>${icon(GLYPH[dir])}<span class="ui-stat__change">${esc(text)}</span>${own}</dd>`;
36
+ const describedby = !own && basisId ? ` aria-describedby="${basisId}"` : '';
37
+ return `<dd class="ui-stat__delta"${describedby}>${lead}${icon(GLYPH[dir])}`
38
+ + `<span class="ui-stat__change">${esc(text)}</span>${own}</dd>`;
36
39
  };
37
40
 
38
41
  // `tone` says whether the change is good news, and nothing else. It is never
39
42
  // inferred from the direction: costs going up and unclassified rows going down
40
43
  // are both real, and a band that paints every rise green is editorialising.
41
44
  // A figure with no change has no news to colour.
42
- const figure = ({ label = '', value = '', delta, trend = '' }, tile, basisId) => {
45
+ const figure = ({ label = '', value = '', caption = '', delta, trend = '' }, tile, basisId) => {
43
46
  const tone = hasChange(delta) && STAT_TONES.includes(delta.tone) && delta.tone !== 'neutral' ? delta.tone : '';
44
47
  const cls = cx('ui-stat', tone && `ui-stat--${tone}`, tile && 'ui-card ui-card--pad-sm');
45
48
  // `trend` is trusted markup — an <svg> the caller drew. The kit sizes and
@@ -47,20 +50,15 @@ const figure = ({ label = '', value = '', delta, trend = '' }, tile, basisId) =>
47
50
  return `<div class="${cls}">`
48
51
  + `<dt class="ui-stat__label">${esc(label)}</dt>`
49
52
  + `<dd class="ui-stat__value">${esc(value)}</dd>`
50
- + deltaHtml(delta, basisId)
53
+ + contextRow(caption, delta, basisId)
51
54
  + (trend ? `<dd class="ui-stat__trend">${trend}</dd>` : '')
52
55
  + '</div>';
53
56
  };
54
57
 
55
- // `basis` is the band's caption, said once above the figures: what every change
56
- // is measured against, or on a band with no changes, what the figures cover. A
57
- // figure measured against something else carries its own `delta.basis`, printed
58
- // beside the change.
59
- //
60
- // The caption comes before the list, the way a table's <caption> does, in every
61
- // layout. It is one statement about all the figures, so it is read before them
62
- // and it sits outside every one of them — under a row of tiles it would read as
63
- // a note on the last card. why: docs/specification.md#stat-bands
58
+ // `basis` is the band's caption: what every change is measured against, said
59
+ // once before the list the way a table's <caption> is, in every layout — under
60
+ // a row of tiles it would read as a note on the last card.
61
+ // why: docs/specification.md#stat-bands
64
62
  export function statBand({ stats = [], variant = 'tiles', basis = '', label, id } = {}) {
65
63
  const v = STAT_VARIANTS.includes(variant) ? variant : 'tiles';
66
64
  const base = id ? esc(id) : `ui-stats-${++seq}`;
@@ -82,7 +82,8 @@
82
82
  display: flex;
83
83
  align-items: center;
84
84
  gap: 10px;
85
- margin: 0 0 5px;
85
+ /* Title to description: the step, not the 5px it was. why: docs/specification.md#spacing-and-rhythm */
86
+ margin: 0 0 var(--space-2);
86
87
  }
87
88
  .ui-card__sub {
88
89
  color: var(--text);
@@ -49,9 +49,25 @@
49
49
  white-space: nowrap;
50
50
  }
51
51
 
52
+ /* A figure's own words about its value, in the caption rank's size and weight
53
+ with the band's tighter leading, as the label takes, so it claims no rank.
54
+ Body ink on no fill; no tone reaches it, having no change to colour. */
55
+ .ui-stat__caption {
56
+ color: var(--text);
57
+ font-size: var(--text-sm);
58
+ font-weight: var(--weight-normal);
59
+ line-height: var(--leading-snug);
60
+ }
61
+
62
+ /* Alone it is the row a change would have taken — the same step down and line
63
+ box — which holds a band of unlike figures on one line. Leading a change it is
64
+ a span in that row, spaced off what follows. */
65
+ dd.ui-stat__caption { margin-top: var(--space-2); }
66
+ .ui-stat__delta .ui-stat__caption { margin-inline-end: var(--space-1); }
67
+
52
68
  .ui-stat__delta {
53
69
  display: flex;
54
- flex-wrap: wrap;
70
+ flex-wrap: nowrap;
55
71
  align-items: center;
56
72
  gap: 2px var(--space-1);
57
73
  margin-top: var(--space-2);
@@ -61,9 +77,21 @@
61
77
  line-height: var(--leading-snug);
62
78
  }
63
79
  .ui-stat__delta svg { width: 14px; height: 14px; flex: none; stroke-width: 2.7; } /* 2.7 of a 24 box drawn at 14px = 1.58 CSS px. docs/specification.md#icons-and-glyphs */
64
- .ui-stat__change { font-variant-numeric: tabular-nums; white-space: nowrap; }
80
+ .ui-stat__change { font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; }
65
81
  .ui-stat__basis { color: var(--text); font-weight: var(--weight-normal); }
66
- .ui-stat__delta--none { color: var(--text); font-weight: var(--weight-normal); }
82
+
83
+ /* The change above does not wrap: wrapped, its arrow ends one line and its number
84
+ starts the next, and a long caption drops its own change below the ones beside
85
+ it. The arrow and the number cannot give way, so the words clip — the whole
86
+ string stays in the markup. Only in that row: a caption standing alone has
87
+ nothing to orphan, so it takes a second line rather than lose words. */
88
+ .ui-stat__delta .ui-stat__caption,
89
+ .ui-stat__basis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
90
+
91
+ /* A figure is never narrower than its content: without these a long line widens it. */
92
+ dd.ui-stat__caption { contain: inline-size; }
93
+ .ui-stat__delta .ui-stat__caption,
94
+ .ui-stat__basis { width: 0; flex-grow: 1; max-width: max-content; }
67
95
 
68
96
  /* Tone is set by the caller and says good or bad news. Direction is the arrow's
69
97
  job, so an unset tone keeps the change neutral whichever way it points. */
@@ -77,14 +105,15 @@
77
105
  .ui-stat--good .ui-stat__trend { color: var(--chip-success-ink); }
78
106
  .ui-stat--bad .ui-stat__trend { color: var(--chip-danger-ink); }
79
107
 
80
- /* The band's caption, above the figures rather than in one: it is what every
81
- change points at, so it sits outside the ranks and takes none of them. The
82
- margin is under it because it leads the row. */
108
+ /* The band's caption, above the figures rather than in one: what every change
109
+ points at. It governs the row, so it takes the caption rank itself and is
110
+ never set under a caption inside one figure, and its margin is below it. */
83
111
  .ui-stats__basis {
84
112
  margin: 0 0 var(--space-5);
85
113
  color: var(--text);
86
- font-size: var(--text-xs);
87
- line-height: var(--leading-snug);
114
+ /* rank: caption */
115
+ font-size: var(--text-sm);
116
+ font-weight: var(--weight-normal);
88
117
  }
89
118
 
90
119
  .ui-stats--tiles .ui-stats__list { gap: var(--space-4); }
@@ -241,3 +241,68 @@ a.ui-identity:hover { text-decoration: underline; }
241
241
  .ui-table--pinned { --ui-table-selection-width: var(--space-12); }
242
242
  .ui-table--pinned .ui-table__selection { box-sizing: border-box; width: var(--ui-table-selection-width); min-width: var(--ui-table-selection-width); max-width: var(--ui-table-selection-width); padding-inline: 0; text-align: center; }
243
243
  .ui-table--pinned .ui-table__selection + .ui-table__identity { left: var(--ui-table-selection-width); }
244
+
245
+ /* ============================================================================
246
+ * Stacked rows at phone width — .ui-table--stack, .ui-table__long
247
+ *
248
+ * The cell rules name both .ui-table and the modifier on purpose: --dense, --compact and
249
+ * __title reach a cell with two classes, and one class would lose to them and leave
250
+ * `nowrap` on a paragraph.
251
+ *
252
+ * The consumer's half — the roles a changed `display` drops — is in the specification.
253
+ * why: docs/specification.md#dense-financial-tables
254
+ * held by: src/styles/table-stack.test.js (layout), stories/table-stack.test.js (roles)
255
+ * ========================================================================== */
256
+
257
+ /* The marked column holds running text at any width. What the modifier adds below the
258
+ phone step is the line of its own. */
259
+ .ui-table td.ui-table__long { white-space: normal; }
260
+
261
+ @media (max-width: 560px) {
262
+ .ui-table.ui-table--stack { display: block; width: 100%; min-width: 0; padding: 0; }
263
+ .ui-table.ui-table--stack > thead {
264
+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
265
+ }
266
+ .ui-table.ui-table--stack > tbody { display: block; }
267
+ .ui-table.ui-table--stack > tbody > tr {
268
+ display: flex; flex-wrap: wrap; align-items: baseline;
269
+ column-gap: var(--space-3); row-gap: var(--space-1);
270
+ /* The inset is on the row, not the table: the hover outline draws at the row's own
271
+ edge, so a pad on the table would put the outline back against the text (#71). It is
272
+ the step a dense cell already pays sideways, so the outline and the row separator keep
273
+ the clearance they had at the width above this one. */
274
+ padding: var(--space-3); border-bottom: 1px solid var(--border);
275
+ }
276
+ .ui-table.ui-table--stack > tbody > tr:last-child { border-bottom: 0; }
277
+ /* `height` is in this list for the same reason `white-space` is: --compact sets a row
278
+ height for a one-line cell, and a block cell takes it as a fixed height rather than a
279
+ floor, so a wrapped paragraph spills through the separator into the next row. A stacked
280
+ cell is as tall as its text. */
281
+ .ui-table.ui-table--stack > tbody > tr > td {
282
+ display: block; width: auto; min-width: 0; max-width: 100%; height: auto;
283
+ padding: 0; border-bottom: 0; white-space: normal; overflow-wrap: break-word;
284
+ }
285
+ .ui-table.ui-table--stack > tbody > tr > td.ui-table__long { flex: 0 0 100%; text-align: left; }
286
+ /* A stacked row has no columns left to pin, stick or inset, and the identity's name is
287
+ what the row is about — the fold hides it only because a pinned column is narrow. */
288
+ .ui-table.ui-table--stack.ui-table--pinned > :where(thead, tbody) > tr > :where(.ui-table__identity, .ui-table__selection) {
289
+ position: static; width: auto; min-width: 0; max-width: none;
290
+ padding-inline: 0; border-right: 0; text-align: left;
291
+ }
292
+ .ui-table.ui-table--stack.ui-table--pinned .ui-identity__name {
293
+ position: static; width: auto; height: auto; max-width: none;
294
+ overflow: visible; clip-path: none;
295
+ }
296
+ /* The bleed now pays for the row's inset instead of the end cells', so the cells still
297
+ start on the card's own text edge and the outline keeps its room on both sides. Same
298
+ step, so the arithmetic is the one the sheet already does for a dense table above; these
299
+ rules exist because a stacked table that is not dense gets no bleed from that one. A
300
+ scroll wrapper owns the bleed when it is the card's child, and owes one step more than
301
+ the table does because the wrapper keeps its own --space-1 of focus clearance. */
302
+ .ui-card > .ui-table--stack {
303
+ width: calc(100% + 2 * var(--space-3)); margin-inline: calc(-1 * var(--space-3));
304
+ }
305
+ .ui-card > .ui-table-scroll:has(> .ui-table--stack) {
306
+ margin-inline: calc(-1 * var(--space-4));
307
+ }
308
+ }