Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/12942-metric-value-scale.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/plugin-dashboard/src/MetricCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -120,7 +120,7 @@ export const MetricCard: React.FC<MetricCardProps & SchemaHostProps> = ({
</div>
) : (
<>
<div className={cn('font-bold', METRIC_TILE_VALUE_CLASS)}>{value}</div>
<div className={cn('font-bold', metricTileValueClass(value))} title={metricTileValueTitle(value)}>{value}</div>
{(trend || trendValue || description) && (
<p className="text-xs text-muted-foreground flex items-center mt-1">
{trend && trendValue && (
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-dashboard/src/MetricWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -480,7 +480,7 @@ export const MetricWidget = ({
</div>
) : (
<>
<div className={cn('font-bold tracking-tight', METRIC_TILE_VALUE_CLASS)}>{displayValue}</div>
<div className={cn('font-bold tracking-tight', metricTileValueClass(displayValue))} title={metricTileValueTitle(displayValue)}>{displayValue}</div>
{(shownTrend || description) && (
<div className="text-xs text-muted-foreground mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 min-w-0">
{shownTrend && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, number> = { '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(<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>{ui}</I18nProvider>);

it('object-metric: a seven-digit money value draws the full figure on the scaled rung, not the clipping face', () => {
wrap(
<SchemaRendererProvider dataSource={undefined as never}>
<SchemaRenderer
schema={{
type: 'object-metric',
properties: { objectName: 'crm_opportunity', label: 'Revenue (Won)', icon: 'dollar-sign', fallbackValue: 1290000, format: '0,0.00' },
} as never}
/>
</SchemaRendererProvider>,
);
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(<MetricWidget label="Pipeline Value" value={2017500} format="0,0.00" />);
const widgetValue = screen.getByText('2,017,500.00');
expect(classesOf(widgetValue)).toEqual(expect.arrayContaining(metricTileValueClass('2,017,500.00').split(' ')));
cleanup();
wrap(<MetricCard title="Pipeline Value" value="2,017,500.00" />);
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(<MetricWidget label="Open Deals" value={4210} />);
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');
});
});
85 changes: 82 additions & 3 deletions packages/plugin-dashboard/src/metricTile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';

Expand All @@ -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;
}
Loading