diff --git a/.changeset/27520-dashboard-readability.md b/.changeset/27520-dashboard-readability.md new file mode 100644 index 00000000000..963682cd852 --- /dev/null +++ b/.changeset/27520-dashboard-readability.md @@ -0,0 +1,15 @@ +--- +'@object-ui/core': patch +'@object-ui/plugin-charts': patch +'@object-ui/plugin-dashboard': patch +--- + +Clause-②: yes (widening: @object-ui/core gains the public export formatMonthBucketLabel; no accept set moves) +Metadata-protocol: none + +Dashboard numbers and chart labels stay readable on a phone: + +- **KPI value.** A dataset-bound KPI tile's value now shrinks to fit its tile, the way the plain metric tiles already did, instead of being cut with an ellipsis (`$1,090,00...` on a phone). Past the smallest size it keeps the ellipsis and shows the full figure as a tooltip. A short value looks as before. +- **Month axis.** A chart over a month-bucketed date dimension labels its axis `Oct 2025` in the reader's locale, not the raw bucket key `2025-10`. The chart tooltip's header reads the same label. The key itself is unchanged in the data, so drill-through and ordering are unaffected. `@object-ui/core` adds `formatMonthBucketLabel(value, locale)`, which answers that label for a `YYYY-MM` key and `undefined` for anything else. +- **Legend swatches.** A bar or area chart's legend draws a colour swatch for every series; it drew none, because those marks fill from a gradient. A comparison (`compareTo`) series' swatch is drawn hollow, so it reads apart from the solid swatch of the series it overlays in the same colour. +- **Horizontal bar labels.** A horizontal bar chart's category labels are drawn in full on a phone, wrapped to at most two lines inside the axis, instead of cut to eight characters (`In Progr…`). A label still too long ends in an ellipsis and shows the full text as a tooltip. diff --git a/ui/packages/core/src/index.ts b/ui/packages/core/src/index.ts index 4951d9569dd..5197f065756 100644 --- a/ui/packages/core/src/index.ts +++ b/ui/packages/core/src/index.ts @@ -164,6 +164,9 @@ export * from './utils/number-display.js'; // below could not import, so a date-valued measure rendered its raw ISO // string. `@object-ui/fields` re-exports these names unchanged. export * from './utils/date-display.js'; +// The display face of a `YYYY-MM` month bucket key (`Oct 2025`) — read by the +// chart axis, which keeps the key itself as the bucket's identity. +export * from './utils/date-bucket-label.js'; // The native date/time control adapters (objectui#3127), moved down from // `@object-ui/fields` so `@object-ui/components`' data table can share them // instead of keeping private copies (objectui#10625). `@object-ui/fields` diff --git a/ui/packages/core/src/utils/__tests__/date-bucket-label.test.ts b/ui/packages/core/src/utils/__tests__/date-bucket-label.test.ts new file mode 100644 index 00000000000..04dae26bcc0 --- /dev/null +++ b/ui/packages/core/src/utils/__tests__/date-bucket-label.test.ts @@ -0,0 +1,46 @@ +/** + * 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. + */ + +/** + * The month bucket key's display face (objectstack#27520 row 15): a + * `month`-granularity date dimension answers `2025-10`, and an axis reads + * `Oct 2025`. + */ + +import { describe, it, expect } from 'vitest'; +import { formatMonthBucketLabel } from '../date-bucket-label'; + +describe('formatMonthBucketLabel', () => { + it('reads a month bucket key as the short month and the year, in the locale', () => { + expect(formatMonthBucketLabel('2025-10', 'en-US')).toBe('Oct 2025'); + expect(formatMonthBucketLabel('2026-01', 'en-US')).toBe('Jan 2026'); + expect(formatMonthBucketLabel('2025-10', 'zh-CN')).toBe(new Date(Date.UTC(2025, 9, 1)).toLocaleDateString('zh-CN', { month: 'short', year: 'numeric', timeZone: 'UTC' })); + }); + + it('names the month the key names whatever the time zone (formatted in UTC)', () => { + // The first of the month at UTC midnight is the previous day west of UTC; + // a local-zone face would print September for `2025-10` there. + expect(formatMonthBucketLabel('2025-10', 'en-US')).not.toMatch(/Sep/); + expect(formatMonthBucketLabel('2025-12', 'en-US')).toBe('Dec 2025'); + }); + + it('keeps a year below 100 as written', () => { + expect(formatMonthBucketLabel('0050-06', 'en-US')).toMatch(/50/); + expect(formatMonthBucketLabel('0050-06', 'en-US')).not.toMatch(/1950/); + }); + + it('answers undefined for every other shape, so the caller keeps the value as written', () => { + for (const v of ['2025-10-01', '2025-Q4', '2025', '2025-13', '2025-00', '2025-1', 'In Progress', '', null, undefined, 202510]) { + expect(formatMonthBucketLabel(v, 'en-US'), String(v)).toBeUndefined(); + } + }); + + it('answers undefined for a locale Intl refuses', () => { + expect(formatMonthBucketLabel('2025-10', 'not a locale!')).toBeUndefined(); + }); +}); diff --git a/ui/packages/core/src/utils/date-bucket-label.ts b/ui/packages/core/src/utils/date-bucket-label.ts new file mode 100644 index 00000000000..4155077f033 --- /dev/null +++ b/ui/packages/core/src/utils/date-bucket-label.ts @@ -0,0 +1,44 @@ +/** + * 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. + */ + +/** + * date-bucket-label — the display face of a month bucket key. + * + * The analytics executor answers a `month`-granularity date dimension with a + * sort-stable bucket KEY, `YYYY-MM` (`2025-10`). The key is the row's identity: + * a drill-through filters by it and the chart orders by it, so it stays as the + * executor wrote it. What a reader sees on an axis is this face instead, + * `Oct 2025` in the display locale. + * + * Only the key shape is recognised: four digits, a dash, a month 01..12, and + * nothing else. A day (`YYYY-MM-DD`) is the date-display path's, and a quarter + * (`2025-Q4`) or a year (`2025`) already reads as written. + * + * The month is formatted in UTC from a UTC date, so no display or browser + * time zone can move it to the neighbouring month. + */ + +const MONTH_BUCKET_KEY_RE = /^(\d{4})-(0[1-9]|1[0-2])$/; + +/** + * `Oct 2025` for the month bucket key `2025-10`, in `locale`; `undefined` for + * any other value, or when `Intl` refuses the locale, so the caller keeps the + * key as written. + */ +export function formatMonthBucketLabel(value: unknown, locale?: string): string | undefined { + if (typeof value !== 'string' || !MONTH_BUCKET_KEY_RE.test(value)) return undefined; + const [year, month] = value.split('-').map(Number); + const date = new Date(0); + // `setUTCFullYear`, not `Date.UTC(year, …)`: the latter maps years 0-99 onto 1900+y. + date.setUTCFullYear(year, month - 1, 1); + try { + return date.toLocaleDateString(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' }); + } catch { + return undefined; + } +} diff --git a/ui/packages/plugin-charts/src/AdvancedChartImpl.dashboardReadability-27520.test.tsx b/ui/packages/plugin-charts/src/AdvancedChartImpl.dashboardReadability-27520.test.tsx new file mode 100644 index 00000000000..f03a098d5c0 --- /dev/null +++ b/ui/packages/plugin-charts/src/AdvancedChartImpl.dashboardReadability-27520.test.tsx @@ -0,0 +1,241 @@ +/** + * 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. + */ + +/** + * Dashboard chart readability (objectstack#27520 rows 15 and 18). + * + * - A month bucket key (`2025-10`) reads `Oct 2025` on the category axis. + * - The legend draws a swatch for a bar series (whose mark fills from a + * gradient, so recharts hands the legend a `url(#…)` it cannot draw), and a + * comparison series' swatch is hollow, apart from the primary's solid one. + * - A horizontal bar's category label is drawn in full on a phone, wrapped + * into the axis width, instead of cut to eight characters; a label that + * still has to be cut carries the full text as its tooltip. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, act } from '@testing-library/react'; + +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: any) => + React.cloneElement(children, { width: 360, height: 320 }), + }; +}); + +import AdvancedChartImpl from './AdvancedChartImpl'; +import { ChartContainer, ChartTooltipContent } from './ChartContainerImpl'; + +const DESKTOP_WIDTH = window.innerWidth; + +afterEach(() => { + cleanup(); + (window as any).innerWidth = DESKTOP_WIDTH; +}); + +// The legend payload registers from a layout effect; read it after a tick. +const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 60)); }); + +const swatches = (c: HTMLElement) => + Array.from(c.querySelectorAll('.recharts-legend-wrapper .rounded-\\[2px\\]')); + +const categoryTicks = (c: HTMLElement) => + Array.from(c.querySelectorAll('.recharts-yAxis .recharts-cartesian-axis-tick-value, .recharts-cartesian-axis-tick-labels text')); + +describe('month bucket keys on the category axis (row 15)', () => { + it('reads `2025-10` as `Oct 2025`', () => { + const { container } = render( + , + ); + const text = container.textContent ?? ''; + expect(text).toContain('Oct 2025'); + expect(text).toContain('Dec 2025'); + expect(text).not.toContain('2025-10'); + }); + + it('CONTROL: a category that only looks partly like a date is drawn as written', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('2025-Q4'); + }); +}); + +describe('legend swatches (row 15)', () => { + const compared = [ + { dataKey: 'n', label: 'Count', variant: 'current' as const }, + { dataKey: 'n__compare', label: 'Count · vs last quarter', variant: 'comparison' as const }, + ]; + const rows = [{ stage: 'Prospecting', n: 3, n__compare: 2 }, { stage: 'Proposal', n: 5, n__compare: 4 }]; + + it('a bar chart draws a colour on every swatch, never a gradient reference', async () => { + const { container } = render( + , + ); + await settle(); + const drawn = swatches(container); + expect(drawn).toHaveLength(2); + for (const swatch of drawn) { + const color = swatch.style.getPropertyValue('--color-bg'); + expect(color).not.toBe(''); + expect(color).not.toMatch(/^url\(/); + } + }); + + it.each(['bar', 'line', 'area'] as const)('%s: the comparison swatch is hollow and the primary one solid, in the same colour', async (chartType) => { + const { container } = render( + , + ); + await settle(); + const [primary, comparison] = swatches(container); + expect(primary.classList.contains('bg-(--color-bg)')).toBe(true); + expect(primary.getAttribute('data-swatch')).toBeNull(); + expect(comparison.getAttribute('data-swatch')).toBe('comparison'); + expect(comparison.classList.contains('bg-transparent')).toBe(true); + expect(comparison.classList.contains('border-(--color-bg)')).toBe(true); + // The overlay mirrors its primary's colour; the swatch shape tells them apart. + expect(comparison.style.getPropertyValue('--color-bg')).toBe(primary.style.getPropertyValue('--color-bg')); + }); +}); + +describe('horizontal bar category labels on a phone (row 18)', () => { + const asPhone = () => { + (window as any).innerWidth = 390; + window.dispatchEvent(new Event('resize')); + }; + const statuses = [ + { status: 'In Progress', n: 4 }, + { status: 'Backlog', n: 2 }, + { status: 'Waiting on the customer to confirm the delivery window', n: 1 }, + ]; + + it('draws "In Progress" whole, not cut to "In Progr…"', () => { + asPhone(); + const { container } = render( + , + ); + const texts = categoryTicks(container).map((t) => Array.from(t.querySelectorAll('tspan')).map((s) => s.textContent).join(' ')); + expect(texts).toContain('In Progress'); + expect(container.textContent).not.toContain('In Progr…'); + }); + + it('wraps a long label to at most two lines and carries the full label as its tooltip', () => { + asPhone(); + const { container } = render( + , + ); + const long = categoryTicks(container).find((t) => t.querySelector('title')); + expect(long).toBeDefined(); + expect(long!.querySelector('title')!.textContent).toBe('Waiting on the customer to confirm the delivery window'); + const lines = Array.from(long!.querySelectorAll('tspan')).map((s) => s.textContent ?? ''); + expect(lines).toHaveLength(2); + expect(lines[1].endsWith('…')).toBe(true); + // A label that fits carries no tooltip. + const backlog = categoryTicks(container).find((t) => t.textContent === 'Backlog'); + expect(backlog?.querySelector('title')).toBeNull(); + }); +}); + +describe('vertical bar category labels on a phone (row 18)', () => { + const asPhone = () => { + (window as any).innerWidth = 390; + window.dispatchEvent(new Event('resize')); + }; + const xTicks = (c: HTMLElement) => + Array.from(c.querySelectorAll('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-value')); + + it('a few buckets rotate and draw "In Progress" whole, where the phone cut drew "In Progr…"', () => { + asPhone(); + const { container } = render( + , + ); + const labels = xTicks(container).map((t) => t.textContent); + expect(labels).toContain('In Progress'); + expect(container.textContent).not.toContain('In Progr…'); + }); + + it('a label still cut carries the full label as its tooltip; a whole one carries none', () => { + const long = 'Waiting on the customer to confirm'; + const { container } = render( + , + ); + const ticks = Array.from(container.querySelectorAll('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-label')); + const cut = ticks.find((t) => (t.textContent ?? '').includes('…')); + expect(cut, 'the long label was not cut, so this measures nothing').toBeDefined(); + expect(cut!.querySelector('title')?.textContent).toBe(long); + const whole = ticks.find((t) => t.querySelector('.recharts-cartesian-axis-tick-value')?.textContent === 'Done'); + expect(whole?.querySelector('title')).toBeNull(); + }); +}); + +describe('a lone bar series painted per category (row 15 control)', () => { + it('draws its legend label with no swatch, since no one colour names it', async () => { + const { container } = render( + , + ); + await settle(); + expect(container.querySelector('.recharts-legend-wrapper')?.textContent).toContain('Projects'); + expect(swatches(container)).toHaveLength(0); + }); +}); + + +describe('the tooltip header names a month bucket the way the axis does (objectstack#27520 row 15)', () => { + const header = (label: string) => { + const { container } = render( + + + , + ); + return container.querySelector('.font-medium')?.textContent; + }; + + it('reads `Oct 2025` for the bucket key `2025-10`', () => { + expect(header('2025-10')).toBe('Oct 2025'); + }); + + it('CONTROL: any other category reads as written', () => { + expect(header('2025-Q4')).toBe('2025-Q4'); + expect(header('In Progress')).toBe('In Progress'); + }); +}); diff --git a/ui/packages/plugin-charts/src/AdvancedChartImpl.labelFitBand-13241.test.tsx b/ui/packages/plugin-charts/src/AdvancedChartImpl.labelFitBand-13241.test.tsx index af69e8abab6..0a9a9dff394 100644 --- a/ui/packages/plugin-charts/src/AdvancedChartImpl.labelFitBand-13241.test.tsx +++ b/ui/packages/plugin-charts/src/AdvancedChartImpl.labelFitBand-13241.test.tsx @@ -113,7 +113,10 @@ describe('an x-axis label rotates and is cut only when its band cannot hold it ( // The cut name is still reachable whole: the pointer over its bar opens // the tooltip, which names the category in full. recharts throttles a // pointer move through `requestAnimationFrame`, so the frame is flushed. - expect(container.textContent).not.toContain('Needs Analysis'); + // The axis draws only the cut; the full name rides in the tick's own + // tooltip (objectstack#27520) and in the chart tooltip below. + expect(labelsOf(ticks)).not.toContain('Needs Analysis'); + expect(ticks[2].closest('.recharts-cartesian-axis-tick-label')?.querySelector('title')?.textContent).toBe('Needs Analysis'); const bar = container.querySelectorAll('.recharts-rectangle')[2] as SVGElement; const x = Number(bar.getAttribute('x')) + Number(bar.getAttribute('width')) / 2; const y = Number(bar.getAttribute('y')) + Number(bar.getAttribute('height')) / 2; diff --git a/ui/packages/plugin-charts/src/AdvancedChartImpl.missingCategoryKey.test.tsx b/ui/packages/plugin-charts/src/AdvancedChartImpl.missingCategoryKey.test.tsx index b3a7146d881..cf9c01612ca 100644 --- a/ui/packages/plugin-charts/src/AdvancedChartImpl.missingCategoryKey.test.tsx +++ b/ui/packages/plugin-charts/src/AdvancedChartImpl.missingCategoryKey.test.tsx @@ -100,7 +100,8 @@ describe('AdvancedChartImpl — unreadable-data guard (framework#4033)', () => { expect(container.querySelector('[data-chart-error="missing-category-key"]')).toBeNull(); expect(container.querySelector('svg')).not.toBeNull(); - expect(container.textContent).toContain('2026-01'); + // The month bucket key draws its display face (objectstack#27520). + expect(container.textContent).toContain('Jan 2026'); expect(warn).not.toHaveBeenCalled(); warn.mockRestore(); }); diff --git a/ui/packages/plugin-charts/src/AdvancedChartImpl.pointAxisEdgeLabels-13390.test.tsx b/ui/packages/plugin-charts/src/AdvancedChartImpl.pointAxisEdgeLabels-13390.test.tsx index 2a24b7d23d4..98db9606c24 100644 --- a/ui/packages/plugin-charts/src/AdvancedChartImpl.pointAxisEdgeLabels-13390.test.tsx +++ b/ui/packages/plugin-charts/src/AdvancedChartImpl.pointAxisEdgeLabels-13390.test.tsx @@ -6,7 +6,8 @@ * LICENSE file in the root directory of this source tree. * * objectui#13390 — a line or area chart cut its last x-axis label at the - * chart's right edge: "2026-10" was painted as "2026". + * chart's right edge: "2026-10" was painted as "2026". The month buckets now + * draw as "Oct 2026" (objectstack#27520), and the same inset holds them. * * recharts lays a line or area chart's categorical x axis out as a POINT * scale, so the first and last categories sit ON the plot edges and each label @@ -70,6 +71,8 @@ const MONTHS = [ { month: '2026-09', tasks: 18 }, { month: '2026-10', tasks: 9 }, ]; +/** The labels the axis draws for them: a month bucket key's face (objectstack#27520). */ +const MONTH_LABELS = ['Aug 2026', 'Sep 2026', 'Oct 2026']; /** Upper estimate of half a tick label's painted width at the 12px tick font. */ const halfLabelPx = (label: string) => ([...label].length * 0.65 * 12) / 2; @@ -105,9 +108,7 @@ function plotOf(container: HTMLElement) { /** Each x-axis tick label: its text, the x it is centred on, and whether it is rotated. */ function xTicksOf(container: HTMLElement) { const ticks = [...container.querySelectorAll('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-value')]; - expect(ticks.map((t) => (t.textContent ?? '').trim()), 'the x axis did not draw every month').toEqual( - MONTHS.map((r) => r.month), - ); + expect(ticks.map((t) => (t.textContent ?? '').trim()), 'the x axis did not draw every month').toEqual(MONTH_LABELS); return ticks.map((t) => ({ label: (t.textContent ?? '').trim(), x: Number(t.getAttribute('x')), diff --git a/ui/packages/plugin-charts/src/AdvancedChartImpl.tsx b/ui/packages/plugin-charts/src/AdvancedChartImpl.tsx index 2d895c87537..96879f6b292 100644 --- a/ui/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/ui/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -37,6 +37,7 @@ import { ReferenceLine, ReferenceArea, Brush, + Text, type SankeyLinkProps, type SankeyNode, type SankeyNodeProps, @@ -47,11 +48,12 @@ import { ChartTooltipContent, ChartLegend, ChartLegendContent, + type LegendSwatch, ChartContainerConfig } from './ChartContainerImpl'; import { mapScatterClick, mapTreemapClick, mapSankeyClick } from './chartDrillEvents'; import { formatterFor, domainFor, ticksFor, plotsWholeNumbers, RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES, effectiveChartFamily, comboBaseFamily, placeYAxes, type DeclaredChartFamily, type NormalizedAxis, type NormalizedSeries, type ValueAxisSlot, type YAxisPlacement } from './normalizeChartSchema'; -import { buildCategoryRank, chartRowBucketId, isRealCalendarDate, toDateInputValue, toDisplayDate, type ChartSegmentClickEvent } from '@object-ui/core'; +import { buildCategoryRank, chartRowBucketId, formatMonthBucketLabel, isRealCalendarDate, toDateInputValue, toDisplayDate, type ChartSegmentClickEvent } from '@object-ui/core'; import { createSafeTranslation, useDisplayLocale, useSafeTranslate } from '@object-ui/i18n'; // Default color fallback for chart series @@ -479,6 +481,104 @@ function estimateLabelWidthPx(label: string): number { return ems * AXIS_TICK_FONT_PX; } +/** + * The most lines a horizontal-bar category label wraps to before it is cut. + * Two keep a bar's label beside its own bar at the row heights a dashboard + * tile draws. + */ +const CATEGORY_LABEL_MAX_LINES = 2; + +/** + * A horizontal-bar category label broken into lines that fit `maxWidthPx`, + * by the same upper estimate the x axis sizes its bands from + * ({@link estimateLabelWidthPx}). Words wrap whole; a word wider than a line + * is cut by glyph. Past {@link CATEGORY_LABEL_MAX_LINES} the last line ends in + * an ellipsis and `truncated` is set, so the tick carries the full label as + * its tooltip. + */ +function wrapCategoryLabel(label: string, maxWidthPx: number): { lines: string[]; truncated: boolean } { + const fits = (text: string) => estimateLabelWidthPx(text) <= maxWidthPx; + if (label === '' || fits(label)) return { lines: [label], truncated: false }; + const lines: string[] = []; + let current = ''; + const pushGlyphs = (word: string) => { + for (const glyph of word) { + if (current !== '' && !fits(current + glyph)) { + lines.push(current); + current = ''; + } + current += glyph; + } + }; + for (const word of label.split(/\s+/u).filter(Boolean)) { + const candidate = current === '' ? word : `${current} ${word}`; + if (fits(candidate)) { + current = candidate; + } else { + if (current !== '') lines.push(current); + current = ''; + if (fits(word)) current = word; + else pushGlyphs(word); + } + } + if (current !== '') lines.push(current); + if (lines.length <= CATEGORY_LABEL_MAX_LINES) return { lines, truncated: false }; + const kept = lines.slice(0, CATEGORY_LABEL_MAX_LINES); + let last = kept[kept.length - 1]; + while (last.length > 0 && !fits(`${last}…`)) last = Array.from(last).slice(0, -1).join(''); + kept[kept.length - 1] = `${last.trimEnd()}…`; + return { lines: kept, truncated: true }; +} + +/** + * What a left category axis keeps between its edge and the label's end: + * recharts' `tickSize` plus its default `tickMargin` of 2. + */ +const CATEGORY_TICK_INSET_PX = RECHARTS_TICK_SIZE + 2; + +/** An unrotated x-axis label on a phone is cut past this many characters. */ +const PHONE_X_LABEL_MAX_CHARS = 8; + +/** + * The x axis's category tick: recharts' own tick text, drawn from the axis's + * `tickFormatter`, plus the full label (`fullLabel`) as a tooltip whenever the + * formatter had to cut it, so a phone's "In Progr…" still names its bucket. + */ +function TitledCategoryTick(props: any) { + const { fullLabel, tickFormatter, payload, index, ...rest } = props; + const shown = String(tickFormatter ? tickFormatter(payload?.value, index) : (payload?.value ?? '')); + const full = String(fullLabel(payload?.value) ?? ''); + const text = {shown}; + return shown === full ? text : {full}{text}; +} + +/** The line advance of a wrapped category label, in em. */ +const CATEGORY_LABEL_LINE_EM = 1.1; + +/** + * The horizontal-bar CATEGORY axis tick: the full label, wrapped into the + * axis width ({@link wrapCategoryLabel}) and centred on its bar, with the full + * label as a tooltip when it had to be cut. It replaces the phone cut to eight + * characters, which left a bar named only by its first letters ("In Progr…"). + */ +function WrappedCategoryTick(props: any) { + const { x, y, payload, textAnchor, orientation, maxWidthPx, formatLabel } = props; + const full = String(formatLabel(payload?.value) ?? ''); + const { lines, truncated } = wrapCategoryLabel(full, maxWidthPx); + // Centre the block on the tick: the first line rises half the extra lines, + // and 0.355em puts a line's middle (not its baseline) on `y`. + const firstDy = `${0.355 - ((lines.length - 1) * CATEGORY_LABEL_LINE_EM) / 2}em`; + return ( + // `orientation` as recharts' own tick text carries it: the side the axis is drawn on. + + {truncated ? {full} : null} + {lines.map((line, i) => ( + {line} + ))} + + ); +} + /** recharts' default chart margin, left plus right (5px each side). */ const CHART_MARGIN_X_PX = 10; @@ -539,8 +639,8 @@ const DATA_LABEL_CHART_MARGIN = { * * ## Why this size * - * Half of a `YYYY-MM` label, the bucket a monthly trend chart draws, at the - * tick font, by the same upper estimate the rotated band is sized from + * Half of a month bucket's label (`Oct 2026`, the face a `YYYY-MM` key draws, + * objectstack#27520), the bucket a monthly trend chart draws, at the tick font, by the same upper estimate the rotated band is sized from * ({@link estimateLabelWidthPx}), less the chart margin already on that side. * A fixed figure, not one sized from each chart's labels, so every line and * area chart lays out the same way. It is not a bound on every label: a long @@ -551,7 +651,7 @@ const DATA_LABEL_CHART_MARGIN = { * derived from recharts' layout, not measured in a browser. */ const POINT_X_AXIS_EDGE_PADDING_PX = - Math.ceil(estimateLabelWidthPx('0000-00') / 2) - CHART_MARGIN_X_PX / 2; + Math.ceil(estimateLabelWidthPx('Mmm 0000') / 2) - CHART_MARGIN_X_PX / 2; /** A module constant so recharts' axis settings memo is not defeated per render. */ const POINT_X_AXIS_PADDING = { left: POINT_X_AXIS_EDGE_PADDING_PX, @@ -2281,8 +2381,10 @@ function AdvancedChartImplInner({ }, []); // Format ISO date strings into compact "MMM D" / "MMM YYYY" labels for X-axis ticks. - // Falls back to the raw value when not parseable as a date. - const formatTick = React.useCallback((value: any): string => { + // Falls back to the raw value when not parseable as a date. The full label: + // the x axis cuts it only where its band needs that (`xAxisTickFormatter`), + // and the horizontal-bar category axis wraps it (`WrappedCategoryTick`). + const formatCategoryLabel = React.useCallback((value: any): string => { if (value == null || value === '') return ''; // A resolved display label for this exact category value — the same // `config[key]?.label` convention already used for series names (below) @@ -2292,6 +2394,10 @@ function AdvancedChartImplInner({ const resolved = config?.[String(value)]?.label; if (resolved != null) return String(resolved); const str = typeof value === 'string' ? value : String(value); + // A month bucket key (`2025-10`, a `month`-granularity date dimension) reads + // as `Oct 2025`; the key itself stays the bucket's identity in the row. + const month = formatMonthBucketLabel(str, displayLocale); + if (month !== undefined) return month; // Detect ISO 8601 date / datetime strings (YYYY-MM-DD or with time component) const isoLike = /^\d{4}-\d{2}-\d{2}/.test(str); if (isoLike) { @@ -2327,9 +2433,8 @@ function AdvancedChartImplInner({ } } } - if (isMobile && str.length > 8) return str.slice(0, 8) + '…'; return str; - }, [data, xAxisKey, isMobile, config, displayLocale]); + }, [data, xAxisKey, config, displayLocale]); // Memoize whether any X-axis label is long enough to warrant angle rotation const hasLongLabels = React.useMemo( @@ -2402,8 +2507,8 @@ function AdvancedChartImplInner({ yAxisUndrawnNotes(yPlacement, yAxes, categoriesRunDown), ); const xTickFormatter = React.useMemo( - () => formatterFor(xAxisSpec?.format, displayLocale) ?? formatTick, - [xAxisSpec?.format, displayLocale, formatTick], + () => formatterFor(xAxisSpec?.format, displayLocale) ?? formatCategoryLabel, + [xAxisSpec?.format, displayLocale, formatCategoryLabel], ); // objectui#13241 — whether the drawn labels fit their bands unrotated, read @@ -2432,19 +2537,33 @@ function AdvancedChartImplInner({ * is sized from the longest label drawn, so the cap is what keeps that band * bounded. See `ROTATED_X_LABEL_MAX_CHARS`. * - * Kept separate from `xTickFormatter` on purpose — that one also formats the - * horizontal-bar family's CATEGORY axis, which is the y axis, sizes its own - * width from the longest label, and already draws all of them. + * An unrotated label on a phone keeps the eight-character cut, unless the + * axis declares a `format`: there the rotated band is what holds a long + * label, so a phone rotates its few labels and draws "In Progress" whole + * where the cut drew "In Progr…" (objectstack#27520). Either cut leaves the + * full label in the tick's tooltip (`TitledCategoryTick`). + * + * Kept separate from `xTickFormatter` on purpose — that one is the full + * label, which the fit and rotation decisions above measure, and which the + * tooltip of a cut label carries. */ const xAxisTickFormatter = React.useMemo(() => { - if (!rotateXLabels) return xTickFormatter; - return (value: any): string => { - const label = String(xTickFormatter(value) ?? ''); - return label.length > ROTATED_X_LABEL_MAX_CHARS - ? `${label.slice(0, ROTATED_X_LABEL_MAX_CHARS - 1)}…` - : label; - }; - }, [rotateXLabels, xTickFormatter]); + if (rotateXLabels) { + return (value: any): string => { + const label = String(xTickFormatter(value) ?? ''); + return label.length > ROTATED_X_LABEL_MAX_CHARS + ? `${label.slice(0, ROTATED_X_LABEL_MAX_CHARS - 1)}…` + : label; + }; + } + if (isMobile && !xAxisSpec?.format) { + return (value: any): string => { + const label = String(xTickFormatter(value) ?? ''); + return label.length > PHONE_X_LABEL_MAX_CHARS ? `${label.slice(0, PHONE_X_LABEL_MAX_CHARS)}…` : label; + }; + } + return xTickFormatter; + }, [rotateXLabels, isMobile, xAxisSpec?.format, xTickFormatter]); // objectui#11796 — the band a rotated x axis reserves, from the longest // label it draws (see `rotatedXAxisHeight`). A number, so the axis props @@ -2644,7 +2763,7 @@ function AdvancedChartImplInner({ // 180-point daily series at 800px still thinned to 11 ticks with zero // overlap, unchanged in kind from before this change. const xAxisCommonProps = React.useMemo(() => ({ - tick: AXIS_TICK_STYLE, + tick: (tickProps: any) => , tickLine: false as const, tickMargin: X_AXIS_TICK_MARGIN, axisLine: false as const, @@ -2668,7 +2787,7 @@ function AdvancedChartImplInner({ textAnchor: 'end' as const, height: rotatedXAxisBand, }), - }), [labelEveryBucket, rotateXLabels, xAxisTickFormatter, xAxisSpec?.title, xAxisAcross, rotatedXAxisBand]); + }), [labelEveryBucket, rotateXLabels, xAxisTickFormatter, xTickFormatter, xAxisSpec?.title, xAxisAcross, rotatedXAxisBand]); // #2942 — the non-series spec families used to fall through the component // map's `|| BarChart` into a bar shell whose series marks all returned @@ -3477,6 +3596,12 @@ function AdvancedChartImplInner({ // Horizontal bar — swap X/Y axis types and orientation. const isHorizontal = chartType === 'horizontal-bar'; + // Its category axis draws each label in full (no phone cut), wrapped into + // this width by `WrappedCategoryTick`. + const categoryTickFormatter = formatterFor(xAxisSpec?.format, displayLocale) ?? formatCategoryLabel; + const categoryAxisWidth = isHorizontal + ? Math.min(140, Math.max(60, Math.ceil(Math.max(0, ...data.map((d) => estimateLabelWidthPx(String(categoryTickFormatter(d?.[xAxisKey]) ?? ''))))) + CATEGORY_TICK_INSET_PX)) + : 0; // Build vertical fill gradients (bar + area) for every colour in play, so // fills read as a polished ramp instead of flat blocks (大屏 look). Inline @@ -3492,6 +3617,36 @@ function AdvancedChartImplInner({ ])); const gslug = (c: string) => 'g' + c.replace(/[^a-zA-Z0-9]/g, ''); + // A series' colour. A comparison series mirrors the colour of the primary + // series it overlays rather than taking a fresh palette slot: the first + // non-comparison series above it. + const seriesColorAt = (sIdx: number): string => { + const s = series[sIdx]; + const isComparison = s?.variant === 'comparison'; + const baseSeries = isComparison + ? (series.slice(0, sIdx).find((p: any) => p.variant !== 'comparison') || series[0]) + : s; + const baseIdx = isComparison ? series.indexOf(baseSeries) : sIdx; + return resolveColor(config[baseSeries.dataKey]?.color || _gpal[baseIdx % _gpal.length] || DEFAULT_CHART_COLOR); + }; + // The legend's swatch per series. A bar or area mark fills from a gradient, + // so the colour recharts hands the legend is a paint-server reference no + // swatch can draw; the swatch takes the series colour instead. A comparison + // swatch is drawn hollow, the way its mark is muted, so it reads apart from + // the primary series it shares a colour with. + // A lone bar series painted per category draws no swatch: no one colour + // names it, and the bars it would sit beside are each another one. + const perCategoryBars = (chartType === 'bar' || chartType === 'horizontal-bar') + && !!categoryColors && series.length === 1 && series[0]?.variant !== 'comparison' && data.length > 1; + const legendSwatches: Record = Object.fromEntries( + series.map((s: any, sIdx: number) => [ + String(s.dataKey), + perCategoryBars + ? { color: seriesColorAt(sIdx), none: true } + : { color: seriesColorAt(sIdx), comparison: s.variant === 'comparison' }, + ]), + ); + return ( @@ -3543,14 +3698,18 @@ function AdvancedChartImplInner({ `position` included: `left` / `right` are its sides here (objectui#10587, see `placeXAxis`). Its `title` follows them too, laid out for an axis running down the plot (objectui#10654). */} + {/* The category labels wrap inside the axis width and carry the + full label as a tooltip when cut (`WrappedCategoryTick`); the + width is sized from the widest label, between 60 and 140 px. */} ( + + )} tickLine={false} axisLine={false} - width={Math.min(140, Math.max(60, Math.max(...data.map(d => String(d[xAxisKey] ?? '').length)) * 7))} - tickFormatter={xTickFormatter} + width={categoryAxisWidth} {...(xAxisDown ? { orientation: xAxisDown } : {})} {...(xAxisSpec?.title ? { label: { value: xAxisSpec.title, ...yAxisTitleLayoutFor(xAxisDown) } } : {})} /> @@ -3608,23 +3767,15 @@ function AdvancedChartImplInner({ {tooltipsEnabled ? } /> : null} {legendVisible ? ( } + content={} {...(isMobile && { verticalAlign: "bottom", wrapperStyle: { fontSize: '11px', paddingTop: '8px' } })} /> ) : null} {annotationEls} {brushEl} {series.map((s: any, sIdx: number) => { - const palette = getPalette(); - // Comparison series should mirror the color of the primary series - // they overlay, not be assigned a fresh palette color. Find the - // first non-comparison series above this one and reuse its color. const isComparison = s.variant === 'comparison'; - const baseSeries = isComparison - ? (series.slice(0, sIdx).find((p: any) => p.variant !== 'comparison') || series[0]) - : s; - const baseIdx = isComparison ? series.indexOf(baseSeries) : sIdx; - const seriesColor = resolveColor(config[baseSeries.dataKey]?.color || palette[baseIdx % palette.length] || DEFAULT_CHART_COLOR); + const seriesColor = seriesColorAt(sIdx); // Spec `series[].yAxis` binds this series to a value-axis slot. Only // meaningful once a second axis is declared, and bound to the id the @@ -3650,8 +3801,7 @@ function AdvancedChartImplInner({ // that has one keeps it, every other bar takes the series colour. // With a comparison overlay the chart has two series, so each // keeps its own series colour. - const primaryCount = series.filter((p: any) => p.variant !== 'comparison').length; - const semanticPerCategory = !!categoryColors && primaryCount === 1 && !isComparison && series.length === 1 && data.length > 1; + const semanticPerCategory = perCategoryBars; const pres = seriesStyle(s, 'bar'); return ( diff --git a/ui/packages/plugin-charts/src/ChartContainerImpl.tsx b/ui/packages/plugin-charts/src/ChartContainerImpl.tsx index 9fbd59c6168..3e78c34d13b 100644 --- a/ui/packages/plugin-charts/src/ChartContainerImpl.tsx +++ b/ui/packages/plugin-charts/src/ChartContainerImpl.tsx @@ -11,6 +11,7 @@ import * as React from "react" import { ResponsiveContainer, Tooltip, Legend } from "recharts" import { useDisplayLocale } from "@object-ui/i18n" +import { formatMonthBucketLabel } from "@object-ui/core" // Utility function to merge class names (inline to avoid external dependency) const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ') @@ -348,7 +349,12 @@ function ChartTooltipContent({ return null } - return
{value}
+ // A month bucket key (`2025-10`) reads as the axis beside it does, + // `Oct 2025`; the row keeps its key (objectstack#27520). + const shown = + typeof value === "string" ? formatMonthBucketLabel(value, displayLocale) ?? value : value + + return
{shown}
}, [ label, labelFormatter, @@ -357,6 +363,7 @@ function ChartTooltipContent({ labelClassName, config, labelKey, + displayLocale, ]) if (!active || !payload?.length) { @@ -445,12 +452,23 @@ function ChartTooltipContent({ const ChartLegend = Legend +/** + * The swatch a legend item draws for a series, keyed by its `dataKey`, when + * the chart knows better than the colour recharts hands the legend: a mark + * filled from a gradient hands it a paint-server reference (`url(#…)`), which + * no swatch can draw. A `comparison` series draws its swatch hollow, the way + * its mark is muted. `none` draws no swatch, only the label: a series whose + * marks each carry another colour. + */ +export type LegendSwatch = { color: string; comparison?: boolean; none?: boolean } + function ChartLegendContent({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey, + swatches, }: any) { const { config } = useChart() @@ -471,6 +489,7 @@ function ChartLegendContent({ .map((item: any) => { const key = `${nameKey || item.dataKey || "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) + const swatch: LegendSwatch | undefined = (swatches as Record | undefined)?.[String(item.dataKey)] return (
svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3" )} > - {itemConfig?.icon && !hideIcon ? ( + {swatch?.none ? null : itemConfig?.icon && !hideIcon ? ( ) : ( // The series colour is author-declared, so it reaches the DOM @@ -487,10 +506,16 @@ function ChartLegendContent({ // same shape as the tooltip indicator above (AGENTS.md styling // carve-out; objectui#10021).
diff --git a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx index 16c6ef2c8fd..43a1ede3d09 100644 --- a/ui/packages/plugin-dashboard/src/DatasetWidget.tsx +++ b/ui/packages/plugin-dashboard/src/DatasetWidget.tsx @@ -99,8 +99,10 @@ import { METRIC_TILE_CONTAINER_CLASS, METRIC_TILE_TITLE_CLASS, METRIC_TILE_MEASURE_LABEL_CLASS, - METRIC_TILE_VALUE_CLASS, + METRIC_TILE_DATASET_VALUE_LADDER, metricTileTooltip, + metricTileValueClass, + metricTileValueTitle, } from './metricTile'; import { DrillDownDrawer } from './DrillDownDrawer'; import { specDefaultWidgetType } from './widgetDispatch'; @@ -1389,6 +1391,7 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { // under the value, in place of the measure label, which then rides only in // the title's tooltip (objectstack#27430). const caption = heading?.description || measureLabel; + const shownValue = formatMeasure(value, f?.format, f?.currency, f?.percentScale, displayLocale); return ( // Positioned only while a re-read is in flight, for the refresh bar: the // idle tile's markup is pinned byte-for-byte (`DatasetWidget.colorVariant`, @@ -1401,7 +1404,10 @@ export function DatasetWidget({ widget, dataSource, heading, onForbidden }: { `./metricTile`). Without one, the tile's markup is unchanged: the label is then the only text naming the value. */} {heading && } - {formatMeasure(value, f?.format, f?.currency, f?.percentScale, displayLocale)} + {/* With a heading the value scales before it would clip: a long + money figure takes the dataset tile's ladder, and past its last + rung keeps the ellipsis with the full figure as its tooltip. */} + {shownValue} {heading && caption && ( {caption} )} diff --git a/ui/packages/plugin-dashboard/src/__tests__/metricTileDatasetValueScale-27520.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/metricTileDatasetValueScale-27520.test.tsx new file mode 100644 index 00000000000..56033af1c46 --- /dev/null +++ b/ui/packages/plugin-dashboard/src/__tests__/metricTileDatasetValueScale-27520.test.tsx @@ -0,0 +1,149 @@ +/** + * 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. + */ + +/** + * objectstack#27520 row 3 — the dataset-bound KPI tile's value scales before + * it would clip. + * + * On a phone the Delivery Operations dashboard draws its KPI tiles two to a + * row, and the Total Budget tile cut `$1,090,000.00` to `$1,090,00...`: the + * dataset-bound tile drew every value at the short face (20 px, 24 px from a + * 160 px tile), while the plain tiles already took the size ladder + * (objectui#12942). Now it takes a ladder of its own, measured against its own + * box, which has no Card inset. + * + * jsdom has no layout, so this file pins the CONTRACT: every step starts where + * the formula in `../metricTile` says, which rung a value takes, and that the + * tile's value node carries it. The measured widths are in the pull request, + * from a browser. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import type { DashboardComponentSchema } from '@object-ui/types'; +// Module scope (AGENTS.md 测试纪律): the barrel registers the dashboard blocks. +import '@object-ui/components'; +import '../index'; +import { DashboardRenderer } from '../DashboardRenderer'; +import { + METRIC_TILE_DATASET_VALUE_LADDER, + METRIC_TILE_VALUE_CLASS, + METRIC_TILE_VALUE_FINAL_SIZES, + 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 short rung's own step for a size: 20 px from 0, 24 px from 160 px. */ +const SHORT_FACE_STEP: Record = { 'text-xl': 0, 'text-2xl': 160 }; + +const BUDGET = '$1,090,000.00'; + +describe('the dataset tile value ladder (objectstack#27520)', () => { + it('has the plain ladder\'s rungs and its short face', () => { + expect(METRIC_TILE_DATASET_VALUE_LADDER.map((r) => r.maxEm)).toEqual(METRIC_TILE_VALUE_LADDER.map((r) => r.maxEm)); + expect(METRIC_TILE_DATASET_VALUE_LADDER[0].sizes).toBe(METRIC_TILE_VALUE_LADDER[0].sizes); + }); + + it('starts every step where its size fits the rung\'s widest value, never before the short face\'s own step', () => { + for (const { maxEm, sizes } of METRIC_TILE_DATASET_VALUE_LADDER.slice(1)) { + const cls = sizes.split(' '); + expect(cls[0]).toBe('text-sm'); + for (const c of cls.slice(1)) { + const step = /^@\[(\d+)px\]:(text-[a-z0-9]+)$/.exec(c); + expect(step, c).not.toBeNull(); + const need = Math.max(Math.ceil(maxEm * PX[step![2]]), SHORT_FACE_STEP[step![2]] ?? 0); + expect(Number(step![1]), `${c} on the ${maxEm} em rung`).toBe(need); + } + } + expect(METRIC_TILE_VALUE_FINAL_SIZES).toBe('text-sm'); + }); + + it('never draws a longer value larger than a shorter one in the same tile', () => { + const rungs = METRIC_TILE_DATASET_VALUE_LADDER.map((r) => r.sizes.split(' ')); + for (let width = 60; width <= 400; width += 1) { + const px = rungs.map((cls) => sizeAt(cls, width)); + expect(px, `at ${width} px`).toEqual([...px].sort((a, b) => b - a)); + } + }); + + it('fits the Total Budget figure at every width from where 14 px holds it', () => { + const em = metricTileValueEm(BUDGET); + const cls = metricTileValueClass(BUDGET, METRIC_TILE_DATASET_VALUE_LADDER).split(' '); + for (let width = Math.ceil(em * 14); width <= 400; width += 1) { + expect(em * sizeAt(cls, width), `at a ${width} px tile`).toBeLessThanOrEqual(width); + } + // The short face did not: 20 px in a 140 px tile, 24 px in a 170 px one. + const short = METRIC_TILE_VALUE_CLASS.split(' '); + expect(em * sizeAt(short, 140)).toBeGreaterThan(140); + expect(em * sizeAt(short, 170)).toBeGreaterThan(170); + }); +}); + +describe('the rendered dataset tile (objectstack#27520)', () => { + const renderTile = (value: number) => + render( + + ({ + rows: [{ budget_sum: value }], + fields: [{ name: 'budget_sum', label: 'Total Budget', type: 'currency', format: '$0,0.00' }], + })), + }} + /> + , + ); + + it('draws a long money figure on its ladder rung, whole, with the figure as its tooltip', async () => { + renderTile(1090000); + const value = await screen.findByText(BUDGET); + const cls = classesOf(value); + expect(cls).toEqual(expect.arrayContaining(metricTileValueClass(BUDGET, METRIC_TILE_DATASET_VALUE_LADDER).split(' '))); + expect(cls).not.toContain('text-xl'); + expect(value.textContent).toBe(BUDGET); + expect(value).toHaveAttribute('title', BUDGET); + }); + + it('CONTROL: a short figure keeps the short face and no tooltip', async () => { + renderTile(42); + const value = await screen.findByText('$42.00'); + expect(classesOf(value)).toEqual(expect.arrayContaining(METRIC_TILE_VALUE_CLASS.split(' '))); + expect(value).not.toHaveAttribute('title'); + }); +}); diff --git a/ui/packages/plugin-dashboard/src/__tests__/metricTileLayout-12623.test.tsx b/ui/packages/plugin-dashboard/src/__tests__/metricTileLayout-12623.test.tsx index 008900c9d57..d15bbd2cafb 100644 --- a/ui/packages/plugin-dashboard/src/__tests__/metricTileLayout-12623.test.tsx +++ b/ui/packages/plugin-dashboard/src/__tests__/metricTileLayout-12623.test.tsx @@ -82,7 +82,9 @@ function expectTileContainer(inner: Element) { describe('dataset-bound metric tile — the layout rule (objectui#12623)', () => { const source = () => ({ queryDataset: vi.fn(async () => ({ - rows: [{ total_amount: 1964500 }], + // A short value: a longer one takes the dataset tile's ladder, pinned in + // `metricTileDatasetValueScale-27520.test.tsx`. + rows: [{ total_amount: 196450 }], fields: [{ name: 'total_amount', label: 'Total Amount', type: 'number' }], })), }); @@ -105,7 +107,7 @@ describe('dataset-bound metric tile — the layout rule (objectui#12623)', () => it('gives the title the first line alone, up to two lines', async () => { renderTile(); - await screen.findByText('1964500'); + await screen.findByText('196450'); const title = screen.getByTestId('dataset-metric-title'); expectTitleRule(title); // Alone on its line: the heading row holds the title and nothing else. @@ -114,12 +116,12 @@ describe('dataset-bound metric tile — the layout rule (objectui#12623)', () => it('draws the value unwrapped, one step smaller below a 160 px tile', async () => { renderTile(); - expectValueRule(await screen.findByText('1964500')); + expectValueRule(await screen.findByText('196450')); }); it('draws the measure label under the value, 11 px, hidden below a 160 px tile, and keeps it in the tooltip', async () => { renderTile(); - const value = await screen.findByText('1964500'); + const value = await screen.findByText('196450'); const label = screen.getByTestId('dataset-metric-label'); expect(value.nextElementSibling).toBe(label); @@ -134,7 +136,7 @@ describe('dataset-bound metric tile — the layout rule (objectui#12623)', () => it('measures every width rule against the tile, which holds the title, the value and the label', async () => { renderTile(); - const value = await screen.findByText('1964500'); + const value = await screen.findByText('196450'); const box = expectTileContainer(value); expect(box).toContainElement(screen.getByTestId('dataset-metric-title')); expect(box).toContainElement(screen.getByTestId('dataset-metric-label')); diff --git a/ui/packages/plugin-dashboard/src/metricTile.ts b/ui/packages/plugin-dashboard/src/metricTile.ts index 9b3a963728e..5dda38fa61b 100644 --- a/ui/packages/plugin-dashboard/src/metricTile.ts +++ b/ui/packages/plugin-dashboard/src/metricTile.ts @@ -12,7 +12,9 @@ * (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`). + * steps start where that size fits (`metricTileValueClass`), on the + * dataset-bound tile too, against its own box. Past 14 px it keeps the + * ellipsis and carries the full figure as its tooltip. * - The caption (dataset-bound tile only) is an 11 px muted line under the * value, dropped below 160 px: the widget's description when one is * authored, else the measure label. The title's tooltip carries the measure @@ -34,9 +36,10 @@ export const METRIC_TILE_TITLE_CLASS = 'min-w-0 line-clamp-2 break-words text-[1 /** * 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. + * short value's face on both tiles. A longer value takes its tile's ladder + * below: {@link METRIC_TILE_VALUE_LADDER} on a plain tile, measured against a + * Card, and {@link METRIC_TILE_DATASET_VALUE_LADDER} on the dataset-bound + * tile, which has no Card inset. */ export const METRIC_TILE_VALUE_CLASS = 'max-w-full overflow-hidden text-ellipsis whitespace-nowrap tabular-nums text-xl @[160px]:text-2xl'; @@ -69,6 +72,25 @@ export const METRIC_TILE_VALUE_LADDER: ReadonlyArray<{ readonly maxEm: number; r { maxEm: 12.5, sizes: 'text-sm @[248px]:text-base @[273px]:text-lg @[298px]:text-xl @[348px]:text-2xl' }, ]; +/** + * The dataset-bound tile's ladder (`DatasetWidget`): the same rungs, measured + * against that tile's own box. It has no Card inset — its value has the + * container's whole content width — so a step starts at `ceil(maxEm * px)`. + * The short rung's two faces bound it from below: no step draws 24 px under a + * 160 px tile, so a longer value is never drawn larger than a shorter one + * beside it. The `metricTileDatasetValueScale-27520` test re-derives these steps. + * + * Literals only: Tailwind's content scanner must see every class. + */ +export const METRIC_TILE_DATASET_VALUE_LADDER: ReadonlyArray<{ readonly maxEm: number; readonly sizes: string }> = [ + { maxEm: 4.5, sizes: 'text-xl @[160px]:text-2xl' }, + { maxEm: 5.5, sizes: 'text-sm @[88px]:text-base @[99px]:text-lg @[110px]:text-xl @[160px]:text-2xl' }, + { maxEm: 6.75, sizes: 'text-sm @[108px]:text-base @[122px]:text-lg @[135px]:text-xl @[162px]:text-2xl' }, + { maxEm: 8, sizes: 'text-sm @[128px]:text-base @[144px]:text-lg @[160px]:text-xl @[192px]:text-2xl' }, + { maxEm: 10, sizes: 'text-sm @[160px]:text-base @[180px]:text-lg @[200px]:text-xl @[240px]:text-2xl' }, + { maxEm: 12.5, sizes: 'text-sm @[200px]:text-base @[225px]:text-lg @[250px]:text-xl @[300px]:text-2xl' }, +]; + /** Past the ladder's last rung: 14 px at every width. */ export const METRIC_TILE_VALUE_FINAL_SIZES = 'text-sm'; @@ -92,12 +114,16 @@ export function metricTileValueEm(text: string): number { } /** - * 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. + * The value node's class for `value`: the rung of `ladder` (the plain tile's + * {@link METRIC_TILE_VALUE_LADDER} unless told otherwise) its width falls in, + * or the 14 px final face past the last rung. */ -export function metricTileValueClass(value: string | number): string { +export function metricTileValueClass( + value: string | number, + ladder: ReadonlyArray<{ readonly maxEm: number; readonly sizes: string }> = METRIC_TILE_VALUE_LADDER, +): string { const em = metricTileValueEm(String(value)); - const rung = METRIC_TILE_VALUE_LADDER.find((r) => em <= r.maxEm); + const rung = ladder.find((r) => em <= r.maxEm); return `${METRIC_TILE_VALUE_BASE} ${rung ? rung.sizes : METRIC_TILE_VALUE_FINAL_SIZES}`; }