diff --git a/.changeset/12942-metric-value-scale.md b/.changeset/12942-metric-value-scale.md new file mode 100644 index 0000000000..c6913ac776 --- /dev/null +++ b/.changeset/12942-metric-value-scale.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-dashboard': patch +--- + +A metric tile's value now scales before it would clip. A long value, such as a seven-digit money figure with cents (`1,290,000.00`), steps down from 24 px to 14 px as the tile narrows, so it fits whole in a 156 px tile instead of ending in an ellipsis. A short value keeps its 20 px / 24 px face. A value too wide for its tile even at 14 px keeps the ellipsis and shows the full figure as its tooltip. This applies to `MetricCard`, `MetricWidget` and every `object-metric` page block. diff --git a/packages/plugin-dashboard/src/MetricCard.tsx b/packages/plugin-dashboard/src/MetricCard.tsx index 369d64b490..cca6fd796c 100644 --- a/packages/plugin-dashboard/src/MetricCard.tsx +++ b/packages/plugin-dashboard/src/MetricCard.tsx @@ -13,7 +13,7 @@ import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { I18nLabel } from '@object-ui/types'; import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react'; import { hostDomProps, type SchemaHostProps } from './schemaHostProps'; -import { METRIC_TILE_CONTAINER_CLASS, METRIC_TILE_TITLE_CLASS, METRIC_TILE_VALUE_CLASS } from './metricTile'; +import { METRIC_TILE_CONTAINER_CLASS, METRIC_TILE_TITLE_CLASS, metricTileValueClass, metricTileValueTitle } from './metricTile'; /** * DOM PASS-THROUGH (objectui#4426) — see `MetricWidget.tsx`'s interface header @@ -120,7 +120,7 @@ export const MetricCard: React.FC = ({ ) : ( <> -
{value}
+
{value}
{(trend || trendValue || description) && (

{trend && trendValue && ( diff --git a/packages/plugin-dashboard/src/MetricWidget.tsx b/packages/plugin-dashboard/src/MetricWidget.tsx index 60305421c5..897bbcf217 100644 --- a/packages/plugin-dashboard/src/MetricWidget.tsx +++ b/packages/plugin-dashboard/src/MetricWidget.tsx @@ -19,7 +19,7 @@ import type { I18nLabel } from '@object-ui/types'; import { ArrowDownIcon, ArrowUpIcon, MinusIcon, AlertCircle, Loader2 } from 'lucide-react'; import { VARIANT_ICON_CLASSES, VARIANT_TEXT_CLASSES, type MetricColorVariant } from './colorVariants'; import { hostDomProps, type SchemaHostProps } from './schemaHostProps'; -import { METRIC_TILE_CONTAINER_CLASS, METRIC_TILE_TITLE_CLASS, METRIC_TILE_VALUE_CLASS } from './metricTile'; +import { METRIC_TILE_CONTAINER_CLASS, METRIC_TILE_TITLE_CLASS, metricTileValueClass, metricTileValueTitle } from './metricTile'; import { formatMetricDeltaPercent, type MetricDelta } from './utils'; const TREND_LABEL_DEFAULTS: Record = { @@ -480,7 +480,7 @@ export const MetricWidget = ({ ) : ( <> -

{displayValue}
+
{displayValue}
{(shownTrend || description) && (
{shownTrend && ( diff --git a/packages/plugin-dashboard/src/__tests__/metricTileValueScale-12942.test.tsx b/packages/plugin-dashboard/src/__tests__/metricTileValueScale-12942.test.tsx new file mode 100644 index 0000000000..03141fd6e1 --- /dev/null +++ b/packages/plugin-dashboard/src/__tests__/metricTileValueScale-12942.test.tsx @@ -0,0 +1,171 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12942 — a metric tile's value scales before it would clip. + * + * On HotCRM's Sales Home the KPI card holds four `object-metric` tiles about + * 156 px wide at 1024 px and 174 px at 1440 px. A money value with cents + * (`1,290,000.00`) drew at 20 px and 24 px and was cut with an ellipsis. Now a + * value past the short rung takes a size ladder whose steps start where each + * size fits the tile's value room. + * + * jsdom has no layout, so this file pins the CONTRACT: which rung a value + * takes (the class string the value node carries), that every step of the + * ladder starts where the formula in `../metricTile` says, and that a short + * value keeps today's face. The measured widths are in the pull request, from + * a browser. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Module scope (AGENTS.md 测试纪律): the barrel registers `object-metric`. +import '@object-ui/components'; +import '../index'; +import { MetricWidget } from '../MetricWidget'; +import { MetricCard } from '../MetricCard'; +import { + METRIC_TILE_VALUE_CLASS, + METRIC_TILE_VALUE_FINAL_SIZES, + METRIC_TILE_VALUE_INSET, + METRIC_TILE_VALUE_LADDER, + metricTileValueClass, + metricTileValueEm, +} from '../metricTile'; + +afterEach(cleanup); + +const classesOf = (el: Element) => (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean); + +/** Tailwind's type scale, in px, for the sizes the ladder uses. */ +const PX: Record = { 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24 }; + +/** The size a class list draws at in a container `width` px wide. */ +function sizeAt(classes: string[], width: number): number { + let px = 0; + let from = -1; + for (const c of classes) { + const step = /^@\[(\d+)px\]:(text-[a-z0-9]+)$/.exec(c); + if (step && PX[step[2]] !== undefined) { + const at = Number(step[1]); + if (width >= at && at > from) { from = at; px = PX[step[2]]; } + } else if (PX[c] !== undefined && from < 0) { + px = PX[c]; + } + } + return px; +} + +// The narrowest and widest tiles the card measured, as the container query +// reads them: the Card's content box, inside its 1 px border. +const NARROW = 156 - 2; +const WIDE = 174 - 2; +const SEVEN_DIGIT_MONEY = '1,290,000.00'; + +describe('the value ladder (objectui#12942)', () => { + it('starts every step where its size fits the rung\'s widest value', () => { + for (const { maxEm, sizes } of METRIC_TILE_VALUE_LADDER) { + for (const c of sizes.split(' ')) { + const step = /^@\[(\d+)px\]:(text-[a-z0-9]+)$/.exec(c); + if (!step) continue; + const need = Math.ceil(METRIC_TILE_VALUE_INSET + maxEm * PX[step[2]]); + // The short rung's 24 px step is today's 160 px: it must not start + // before the value fits; every other step starts exactly there. + if (maxEm === METRIC_TILE_VALUE_LADDER[0].maxEm) expect(Number(step[1])).toBeGreaterThanOrEqual(need); + else expect(Number(step[1]), `${c} on the ${maxEm} em rung`).toBe(need); + } + } + }); + + it('gives every rung past the short one a 14 px floor, and climbs as the tile widens', () => { + for (const { sizes } of METRIC_TILE_VALUE_LADDER.slice(1)) { + const cls = sizes.split(' '); + expect(cls[0]).toBe('text-sm'); + const at = cls.slice(1).map((c) => Number(/^@\[(\d+)px\]:/.exec(c)?.[1])); + expect(at).toEqual([...at].sort((a, b) => a - b)); + } + expect(METRIC_TILE_VALUE_FINAL_SIZES).toBe('text-sm'); + }); + + it('reads a seven-digit money value as wider than the short rung, and a count as within it', () => { + expect(metricTileValueEm(SEVEN_DIGIT_MONEY)).toBeGreaterThan(METRIC_TILE_VALUE_LADDER[0].maxEm); + expect(metricTileValueEm('4,210')).toBeLessThanOrEqual(METRIC_TILE_VALUE_LADDER[0].maxEm); + }); + + it('fits a seven-digit money value at the narrowest and widest measured tile', () => { + const em = metricTileValueEm(SEVEN_DIGIT_MONEY); + const cls = metricTileValueClass(SEVEN_DIGIT_MONEY).split(' '); + for (const width of [NARROW, WIDE]) { + const px = sizeAt(cls, width); + expect(px).toBeGreaterThan(0); + expect(em * px, `at a ${width} px container`).toBeLessThanOrEqual(width - METRIC_TILE_VALUE_INSET); + } + // Today's face did not: 20 px at the narrow tile, 24 px at the wide one. + const today = METRIC_TILE_VALUE_CLASS.split(' '); + expect(em * sizeAt(today, NARROW)).toBeGreaterThan(NARROW - METRIC_TILE_VALUE_INSET); + expect(em * sizeAt(today, WIDE)).toBeGreaterThan(WIDE - METRIC_TILE_VALUE_INSET); + }); + + it('takes the 14 px final face past the last rung', () => { + const huge = '123,456,789,012,345,678.00'; + expect(metricTileValueEm(huge)).toBeGreaterThan(METRIC_TILE_VALUE_LADDER[METRIC_TILE_VALUE_LADDER.length - 1].maxEm); + expect(metricTileValueClass(huge)).toBe(METRIC_TILE_VALUE_CLASS.replace(' text-xl @[160px]:text-2xl', ' text-sm')); + }); +}); + +describe('rendered tiles (objectui#12942)', () => { + const wrap = (ui: React.ReactElement) => + render({ui}); + + it('object-metric: a seven-digit money value draws the full figure on the scaled rung, not the clipping face', () => { + wrap( + + + , + ); + const value = screen.getByText(SEVEN_DIGIT_MONEY); + const cls = classesOf(value); + expect(cls).toEqual(expect.arrayContaining(metricTileValueClass(SEVEN_DIGIT_MONEY).split(' '))); + expect(cls).not.toContain('@[160px]:text-2xl'); + expect(sizeAt(cls, NARROW)).toBe(14); + expect(sizeAt(cls, WIDE)).toBe(18); + // The figure is whole in the DOM, and in the tooltip. + expect(value.textContent).toBe(SEVEN_DIGIT_MONEY); + expect(value).toHaveAttribute('title', SEVEN_DIGIT_MONEY); + }); + + it('MetricWidget and MetricCard read the same ladder', () => { + wrap(); + const widgetValue = screen.getByText('2,017,500.00'); + expect(classesOf(widgetValue)).toEqual(expect.arrayContaining(metricTileValueClass('2,017,500.00').split(' '))); + cleanup(); + wrap(); + const cardValue = screen.getByText('2,017,500.00'); + expect(classesOf(cardValue)).toEqual(expect.arrayContaining(metricTileValueClass('2,017,500.00').split(' '))); + expect(cardValue).toHaveAttribute('title', '2,017,500.00'); + }); + + it('CONTROL: a short value keeps today\'s face — 20 px below a 160 px tile, 24 px from it — and no tooltip', () => { + wrap(); + const value = screen.getByText('4,210'); + const cls = classesOf(value); + expect(cls).toEqual(expect.arrayContaining(METRIC_TILE_VALUE_CLASS.split(' '))); + expect(cls).not.toContain('text-sm'); + expect(sizeAt(cls, NARROW)).toBe(20); + expect(sizeAt(cls, WIDE)).toBe(24); + expect(value).not.toHaveAttribute('title'); + }); +}); diff --git a/packages/plugin-dashboard/src/metricTile.ts b/packages/plugin-dashboard/src/metricTile.ts index 986008f530..2d07f6fb14 100644 --- a/packages/plugin-dashboard/src/metricTile.ts +++ b/packages/plugin-dashboard/src/metricTile.ts @@ -8,8 +8,11 @@ * * - The title owns the first line: 13 px / 500, up to two lines, an ellipsis * on the second. Nothing shares its line but the plain card's icon chip. - * - The value never wraps. It is one step smaller (20 px) in a tile narrower - * than 160 px, 24 px from 160 px up. + * - The value never wraps, and it scales before it would clip + * (objectui#12942). A short value is one step smaller (20 px) in a tile + * narrower than 160 px, 24 px from 160 px up. A longer one — a seven-digit + * money figure with cents — takes a size ladder from 14 px to 24 px whose + * steps start where that size fits (`metricTileValueClass`). * - The measure label (dataset-bound tile only) is an 11 px muted line under * the value, dropped below 160 px. The title's tooltip carries it, so it is * still there at every width. @@ -28,9 +31,75 @@ export const METRIC_TILE_CONTAINER_CLASS = '@container'; /** The title: owns the first line, at most two lines, an ellipsis on the second. */ export const METRIC_TILE_TITLE_CLASS = 'min-w-0 line-clamp-2 break-words text-[13px] font-medium leading-snug text-foreground'; -/** The value: never wraps; 20 px below a 160 px tile, 24 px from 160 px up. */ +/** + * The value: never wraps; 20 px below a 160 px tile, 24 px from 160 px up. The + * short value's face on a plain tile, and the dataset-bound tile's value at + * every length: that tile has no Card inset, so its value has the container's + * whole width and the ladder below, measured against a Card, does not apply. + */ export const METRIC_TILE_VALUE_CLASS = 'max-w-full overflow-hidden text-ellipsis whitespace-nowrap tabular-nums text-xl @[160px]:text-2xl'; +/** + * The value's size ladder, chosen by how wide the value is (objectui#12942). + * + * A plain tile (`MetricCard`, `MetricWidget`: a `Card`, 1 px border, the value + * in `CardContent`'s 24 px side padding) gives its value the container's width + * less {@link METRIC_TILE_VALUE_INSET} px — the container query reads the + * Card's content box, inside the border. A value `em` wide fits at a size of + * `px` from a container `INSET + em * px` wide, so each `@[N px]:` step below + * starts at `ceil(INSET + maxEm * px)` for its rung's widest value. The + * `metricTileValueScale-12942` test re-derives every step from that formula. + * + * The first rung is the short value's face, {@link METRIC_TILE_VALUE_CLASS}'s + * sizes unchanged. Every later rung starts at 14 px; past the last one, 14 px + * at every width. A value still wider than its tile at 14 px keeps the + * ellipsis, and the value node carries the full figure as its tooltip. + * + * Literals only: Tailwind's content scanner must see every class. + */ +export const METRIC_TILE_VALUE_INSET = 48; + +export const METRIC_TILE_VALUE_LADDER: ReadonlyArray<{ readonly maxEm: number; readonly sizes: string }> = [ + { maxEm: 4.5, sizes: 'text-xl @[160px]:text-2xl' }, + { maxEm: 5.5, sizes: 'text-sm @[136px]:text-base @[147px]:text-lg @[158px]:text-xl @[180px]:text-2xl' }, + { maxEm: 6.75, sizes: 'text-sm @[156px]:text-base @[170px]:text-lg @[183px]:text-xl @[210px]:text-2xl' }, + { maxEm: 8, sizes: 'text-sm @[176px]:text-base @[192px]:text-lg @[208px]:text-xl @[240px]:text-2xl' }, + { maxEm: 10, sizes: 'text-sm @[208px]:text-base @[228px]:text-lg @[248px]:text-xl @[288px]:text-2xl' }, + { maxEm: 12.5, sizes: 'text-sm @[248px]:text-base @[273px]:text-lg @[298px]:text-xl @[348px]:text-2xl' }, +]; + +/** Past the ladder's last rung: 14 px at every width. */ +export const METRIC_TILE_VALUE_FINAL_SIZES = 'text-sm'; + +const METRIC_TILE_VALUE_BASE = 'max-w-full overflow-hidden text-ellipsis whitespace-nowrap tabular-nums'; + +/** + * How wide `text` draws, in em, at the tile's bold tabular figures — an upper + * bound read off Inter, the console's face: a digit is 0.65 em, a digit + * separator (`,` `.` `'` or a space) 0.27 em, a sign or currency symbol 0.7 em, + * and any other character a full em (letters, CJK). + */ +export function metricTileValueEm(text: string): number { + let em = 0; + for (const ch of text) { + if (ch >= '0' && ch <= '9') em += 0.65; + else if (/[.,'\s]/u.test(ch)) em += 0.27; + else if (/[\p{Sc}%+\-\u2212]/u.test(ch)) em += 0.7; + else em += 1; + } + return em; +} + +/** + * The value node's class for `value`: the rung of {@link METRIC_TILE_VALUE_LADDER} + * its width falls in, or the 14 px final face past the last rung. + */ +export function metricTileValueClass(value: string | number): string { + const em = metricTileValueEm(String(value)); + const rung = METRIC_TILE_VALUE_LADDER.find((r) => em <= r.maxEm); + return `${METRIC_TILE_VALUE_BASE} ${rung ? rung.sizes : METRIC_TILE_VALUE_FINAL_SIZES}`; +} + /** The measure label: an 11 px muted line under the value, dropped below 160 px. */ export const METRIC_TILE_MEASURE_LABEL_CLASS = 'hidden max-w-full truncate text-[11px] text-muted-foreground @[160px]:block'; @@ -43,3 +112,13 @@ export function metricTileTooltip(title: string | undefined, description?: strin const text = [description || title, measureLabel].filter(Boolean).join('\n'); return text || undefined; } + +/** + * The value node's tooltip: the full figure, for a value past the short rung — + * the one place it survives in a tile too narrow even for 14 px. A short value + * draws none, as before. + */ +export function metricTileValueTitle(value: string | number): string | undefined { + const text = String(value); + return metricTileValueEm(text) > METRIC_TILE_VALUE_LADDER[0].maxEm ? text : undefined; +}