diff --git a/ui/css/index.css b/ui/css/index.css index 7c85242d..68604edb 100644 --- a/ui/css/index.css +++ b/ui/css/index.css @@ -684,14 +684,23 @@ button .loading-value { opacity: 1; } +.currency-value-wrap { + display: inline-block; + position: relative; + max-width: 100%; + min-width: 0; + vertical-align: bottom; +} + .currency-value { - display: inline-flex; - align-items: center; - gap: 0.35rem; + display: inline-block; max-width: 100%; min-width: 0; + overflow: hidden; font: inherit; color: inherit; + text-overflow: ellipsis; + vertical-align: bottom; white-space: nowrap; } @@ -728,6 +737,18 @@ button.currency-value.copyable:hover:not(:disabled) { color: var(--danger); } +.currency-value-measure { + position: absolute; + top: 0; + left: 0; + max-width: none; + overflow: visible; + text-overflow: clip; + visibility: hidden; + white-space: nowrap; + pointer-events: none; +} + .timestamp-value { font: inherit; color: inherit; diff --git a/ui/dist/css/index.css b/ui/dist/css/index.css index 7c85242d..68604edb 100644 --- a/ui/dist/css/index.css +++ b/ui/dist/css/index.css @@ -684,14 +684,23 @@ button .loading-value { opacity: 1; } +.currency-value-wrap { + display: inline-block; + position: relative; + max-width: 100%; + min-width: 0; + vertical-align: bottom; +} + .currency-value { - display: inline-flex; - align-items: center; - gap: 0.35rem; + display: inline-block; max-width: 100%; min-width: 0; + overflow: hidden; font: inherit; color: inherit; + text-overflow: ellipsis; + vertical-align: bottom; white-space: nowrap; } @@ -728,6 +737,18 @@ button.currency-value.copyable:hover:not(:disabled) { color: var(--danger); } +.currency-value-measure { + position: absolute; + top: 0; + left: 0; + max-width: none; + overflow: visible; + text-overflow: clip; + visibility: hidden; + white-space: nowrap; + pointer-events: none; +} + .timestamp-value { font: inherit; color: inherit; diff --git a/ui/ts/components/CurrencyValue.tsx b/ui/ts/components/CurrencyValue.tsx index 82ae8194..f832afb6 100644 --- a/ui/ts/components/CurrencyValue.tsx +++ b/ui/ts/components/CurrencyValue.tsx @@ -1,11 +1,12 @@ -import { useEffect } from 'preact/hooks' +import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks' import { LoadingText } from './LoadingText.js' import { useCopyToClipboard } from '../hooks/useCopyToClipboard.js' -import { formatCurrencyBalance, formatRoundedCurrencyBalance } from '../lib/formatters.js' +import { formatCompactCurrencyBalance, formatCurrencyBalance, formatRoundedCurrencyBalance } from '../lib/formatters.js' import { getMetricPlaceholderPresentation } from '../lib/userCopy.js' type CurrencyValueProps = { className?: string + compactWhenOverflow?: boolean decimals?: number loading?: boolean copyable?: boolean @@ -14,8 +15,13 @@ type CurrencyValueProps = { value: bigint | undefined } -export function CurrencyValue({ className = '', copyable = true, decimals = 2, loading = false, suffix = '', units = 18, value }: CurrencyValueProps) { +export function CurrencyValue({ className = '', compactWhenOverflow = false, copyable = true, decimals = 2, loading = false, suffix = '', units = 18, value }: CurrencyValueProps) { const { copied, copyText } = useCopyToClipboard() + const buttonRef = useRef(null) + const spanRef = useRef(null) + const measureRef = useRef(null) + const [shouldCompact, setShouldCompact] = useState(false) + const copiedValue = copied.value const exactValue = value === undefined ? undefined : formatCurrencyBalance(value, units) const exactSuffix = suffix === '' ? '' : ` ${suffix}` @@ -23,31 +29,74 @@ export function CurrencyValue({ className = '', copyable = true, decimals = 2, l copied.value = false }, [exactValue]) + const displayValue = value === undefined ? undefined : `≈ ${formatRoundedCurrencyBalance(value, units, decimals)}${exactSuffix}` + const compactDisplayValue = value === undefined ? undefined : `≈ ${formatCompactCurrencyBalance(value, units)}${exactSuffix}` + + useLayoutEffect(() => { + if (!compactWhenOverflow || value === undefined || displayValue === undefined) { + setShouldCompact(false) + return + } + + const element = buttonRef.current ?? spanRef.current + const measureElement = measureRef.current + if (element === null || measureElement === null) { + setShouldCompact(false) + return + } + + const updateCompaction = () => { + if (copied.value) return + measureElement.textContent = displayValue + const shouldUseCompactValue = measureElement.getBoundingClientRect().width > element.clientWidth + 1 + measureElement.textContent = '' + setShouldCompact(shouldUseCompactValue) + } + + updateCompaction() + + if (typeof ResizeObserver === 'undefined') return + + const observer = new ResizeObserver(() => { + updateCompaction() + }) + observer.observe(element) + + return () => { + observer.disconnect() + } + }, [compactWhenOverflow, copiedValue, displayValue, value]) + if (loading) { return Loading... } - if (value === undefined) { + if (value === undefined || exactValue === undefined || displayValue === undefined || compactDisplayValue === undefined) { return {getMetricPlaceholderPresentation(value)?.placeholder} } - const resolvedExactValue = exactValue ?? formatCurrencyBalance(value, units) - const roundedValue = formatRoundedCurrencyBalance(value, units, decimals) - const displayValue = `≈ ${roundedValue}${exactSuffix}` - const exactTitle = `${resolvedExactValue}${exactSuffix}` + const resolvedDisplayValue = compactWhenOverflow && shouldCompact && !copiedValue ? compactDisplayValue : displayValue + const exactTitle = `${exactValue}${exactSuffix}` const valueClassName = `currency-value${copyable ? ' copyable' : ''} ${className}` + const measureClassName = `currency-value currency-value-measure ${className}` if (!copyable) { return ( - - {displayValue} + + + {resolvedDisplayValue} + + ) } return ( - + + + ) } diff --git a/ui/ts/components/OverviewPanels.tsx b/ui/ts/components/OverviewPanels.tsx index 765f0843..9fb2d9d5 100644 --- a/ui/ts/components/OverviewPanels.tsx +++ b/ui/ts/components/OverviewPanels.tsx @@ -60,13 +60,13 @@ export function OverviewPanels({ {showAccountBalances ? ( <> - + - + - + ) : undefined} diff --git a/ui/ts/lib/formatters.ts b/ui/ts/lib/formatters.ts index 022c60f1..3e19bb5e 100644 --- a/ui/ts/lib/formatters.ts +++ b/ui/ts/lib/formatters.ts @@ -4,6 +4,7 @@ const MILLISECONDS_PER_SECOND = 1000 const SECONDS_PER_MINUTE = 60n const SECONDS_PER_HOUR = 60n * SECONDS_PER_MINUTE const SECONDS_PER_DAY = 24n * SECONDS_PER_HOUR +const SI_SUFFIXES = ['k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] as const function formatGroupedInteger(value: bigint) { return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ') @@ -27,6 +28,41 @@ function assertNonNegativeInteger(value: number, label: string) { if (value < 0) throw new RangeError(`${label} must be non-negative`) } +function formatTrimmedDecimal(integerPart: bigint, fractionalPart: bigint, decimals: number) { + if (decimals === 0 || fractionalPart === 0n) return integerPart.toString() + + return `${integerPart}.${fractionalPart.toString().padStart(decimals, '0').replace(/0+$/, '')}` +} + +function formatRoundedScaledValue(value: bigint, divisor: bigint, decimals: number) { + const scale = 10n ** BigInt(decimals) + const rounded = (value * scale + divisor / 2n) / divisor + const integerPart = rounded / scale + const fractionalPart = rounded % scale + + return { + integerPart, + text: formatTrimmedDecimal(integerPart, fractionalPart, decimals), + } +} + +function formatScientificCurrencyBalance(value: bigint, units: number, decimals: number) { + const isNegative = value < 0n + const absoluteValue = isNegative ? -value : value + const unitBase = 10n ** BigInt(units) + const wholeUnits = absoluteValue / unitBase + let exponent = wholeUnits.toString().length - 1 + + while (true) { + const divisor = 10n ** BigInt(exponent) * unitBase + const rounded = formatRoundedScaledValue(absoluteValue, divisor, decimals) + if (rounded.integerPart < 10n) { + return `${isNegative ? '-' : ''}${rounded.text}E${exponent}` + } + exponent += 1 + } +} + function formatTimestampPart(value: number) { return value.toString().padStart(2, '0') } @@ -76,6 +112,35 @@ export function formatRoundedCurrencyBalance(value: bigint | undefined, units: n return `${prefix}${formatGroupedInteger(integerPart)}.${fractionalPart.toString().padStart(effectiveDecimals, '0')}` } +export function formatCompactCurrencyBalance(value: bigint | undefined, units: number = 18, decimals: number = 1) { + if (value === undefined) return '—' + assertNonNegativeInteger(units, 'Units') + assertInteger(decimals, 'Decimals') + if (decimals < 0) return formatCurrencyBalance(value, units) + + const isNegative = value < 0n + const absoluteValue = isNegative ? -value : value + const unitBase = 10n ** BigInt(units) + + if (absoluteValue < 1000n * unitBase) { + return formatRoundedCurrencyBalance(value, units, decimals) + } + + const wholeUnits = absoluteValue / unitBase + let suffixIndex = Math.floor((wholeUnits.toString().length - 1) / 3) - 1 + + while (suffixIndex < SI_SUFFIXES.length) { + const divisor = 1000n ** BigInt(suffixIndex + 1) * unitBase + const rounded = formatRoundedScaledValue(absoluteValue, divisor, decimals) + if (rounded.integerPart < 1000n) { + return `${isNegative ? '-' : ''}${rounded.text}${SI_SUFFIXES[suffixIndex]}` + } + suffixIndex += 1 + } + + return formatScientificCurrencyBalance(value, units, decimals) +} + export function formatTimestamp(timestamp: bigint) { if (timestamp === 0n) return 'Immediate' return formatUtcTimestamp(timestamp) diff --git a/ui/ts/tests/currencyValue.test.tsx b/ui/ts/tests/currencyValue.test.tsx new file mode 100644 index 00000000..4e734dac --- /dev/null +++ b/ui/ts/tests/currencyValue.test.tsx @@ -0,0 +1,147 @@ +/// + +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { fireEvent, within } from '@testing-library/dom' +import { act } from 'preact/test-utils' +import { CurrencyValue } from '../components/CurrencyValue.js' +import { installDomEnvironment } from './testUtils/domEnvironment.js' +import { renderIntoDocument } from './testUtils/renderIntoDocument.js' + +describe('CurrencyValue', () => { + let restoreDomEnvironment: (() => void) | undefined + let cleanupRenderedComponent: (() => Promise) | undefined + let setClientWidth = (_nextWidth: number) => undefined + let setMeasureWidth = (_nextWidth: number) => undefined + let triggerResizeObservers = () => undefined + + async function renderCurrencyValue(overrides: Partial[0]> = {}) { + const baseProps: Parameters[0] = { + compactWhenOverflow: true, + suffix: 'ETH', + value: 999999990000n * 10n ** 18n, + } + + const renderedComponent = await renderIntoDocument() + cleanupRenderedComponent = renderedComponent.cleanup + return within(document.body) + } + + beforeEach(() => { + const domEnvironment = installDomEnvironment() + restoreDomEnvironment = domEnvironment.cleanup + + let currentClientWidth = 200 + let currentMeasureWidth = 120 + const resizeObservers: MockResizeObserver[] = [] + const originalGetBoundingClientRect = domEnvironment.window.HTMLElement.prototype.getBoundingClientRect + + Object.defineProperty(domEnvironment.window.HTMLElement.prototype, 'clientWidth', { + configurable: true, + get() { + if (this.classList.contains('currency-value')) return currentClientWidth + return 0 + }, + }) + + domEnvironment.window.HTMLElement.prototype.getBoundingClientRect = function () { + if (this.classList.contains('currency-value-measure')) { + return new domEnvironment.window.DOMRect(0, 0, currentMeasureWidth, 0) + } + return originalGetBoundingClientRect.call(this) + } + + class MockResizeObserver implements ResizeObserver { + callback: ResizeObserverCallback + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + resizeObservers.push(this) + } + + disconnect() {} + + observe(_target: Element, _options?: ResizeObserverOptions) {} + + unobserve(_target: Element) {} + } + + Reflect.set(globalThis, 'ResizeObserver', MockResizeObserver) + Reflect.set(navigator, 'clipboard', { + writeText: mock(async () => undefined), + }) + + setClientWidth = nextWidth => { + currentClientWidth = nextWidth + } + + setMeasureWidth = nextWidth => { + currentMeasureWidth = nextWidth + } + + triggerResizeObservers = () => { + for (const observer of resizeObservers) { + observer.callback([], observer) + } + } + }) + + afterEach(async () => { + await cleanupRenderedComponent?.() + cleanupRenderedComponent = undefined + Reflect.deleteProperty(globalThis, 'ResizeObserver') + triggerResizeObservers = () => undefined + setClientWidth = (_nextWidth: number) => undefined + setMeasureWidth = (_nextWidth: number) => undefined + restoreDomEnvironment?.() + restoreDomEnvironment = undefined + }) + + test('compacts a large balance when the normal display value does not fit', async () => { + setClientWidth(80) + setMeasureWidth(180) + + const documentQueries = await renderCurrencyValue() + const copyButton = documentQueries.getByRole('button', { name: 'Copy exact value 999 999 990 000' }) + expect(copyButton.textContent).toBe('≈ 1T ETH') + }) + + test('keeps the full display value when enough width is available', async () => { + setClientWidth(240) + setMeasureWidth(180) + + const documentQueries = await renderCurrencyValue() + const copyButton = documentQueries.getByRole('button', { name: 'Copy exact value 999 999 990 000' }) + expect(copyButton.textContent).toBe('≈ 999 999 990 000.00 ETH') + }) + + test('re-expands from compact to full after a resize observer update', async () => { + setClientWidth(80) + setMeasureWidth(180) + + const documentQueries = await renderCurrencyValue() + const copyButton = documentQueries.getByRole('button', { name: 'Copy exact value 999 999 990 000' }) + expect(copyButton.textContent).toBe('≈ 1T ETH') + + setClientWidth(240) + await act(() => { + triggerResizeObservers() + }) + + expect(copyButton.textContent).toBe('≈ 999 999 990 000.00 ETH') + }) + + test('keeps the exact hover title and copy label while compacted', async () => { + setClientWidth(80) + setMeasureWidth(180) + + const documentQueries = await renderCurrencyValue() + const copyButton = documentQueries.getByRole('button', { name: 'Copy exact value 999 999 990 000' }) + + expect(copyButton.getAttribute('title')).toBe('999 999 990 000 ETH') + await act(async () => { + fireEvent.click(copyButton) + await Promise.resolve() + }) + expect(copyButton.textContent).toBe('Copied') + }) +}) diff --git a/ui/ts/tests/formatters.test.ts b/ui/ts/tests/formatters.test.ts index e57d247f..4e7be897 100644 --- a/ui/ts/tests/formatters.test.ts +++ b/ui/ts/tests/formatters.test.ts @@ -1,7 +1,7 @@ /// import { describe, expect, test } from 'bun:test' -import { formatCurrencyInputBalance, formatDuration, formatRelativeTimestamp, formatRoundedCurrencyBalance, formatTimestamp } from '../lib/formatters.js' +import { formatCompactCurrencyBalance, formatCurrencyInputBalance, formatDuration, formatRelativeTimestamp, formatRoundedCurrencyBalance, formatTimestamp } from '../lib/formatters.js' void describe('formatting helpers', () => { void test('formatRoundedCurrencyBalance rounds positive balances without a decimal part when decimals are zero', () => { @@ -21,6 +21,32 @@ void describe('formatting helpers', () => { expect(formatCurrencyInputBalance(1234567890000000000000n)).toBe('1234.56789') }) + void describe('formatCompactCurrencyBalance', () => { + void test('formats thousands with SI suffixes', () => { + expect(formatCompactCurrencyBalance(10000n * 10n ** 18n)).toBe('10k') + }) + + void test('formats millions with a single decimal place', () => { + expect(formatCompactCurrencyBalance(1234000n * 10n ** 18n)).toBe('1.2M') + }) + + void test('carries rounded values into the next suffix', () => { + expect(formatCompactCurrencyBalance(999999990000n * 10n ** 18n)).toBe('1T') + }) + + void test('preserves the sign for negative values', () => { + expect(formatCompactCurrencyBalance(-1250n * 10n ** 18n)).toBe('-1.3k') + }) + + void test('supports non-18-decimal token units', () => { + expect(formatCompactCurrencyBalance(1234567890000n, 6)).toBe('1.2M') + }) + + void test('falls back to scientific notation beyond yotta', () => { + expect(formatCompactCurrencyBalance(1234000000000000000000000000n, 0)).toBe('1.2E27') + }) + }) + void describe('timestamp formatting', () => { void test('formatTimestamp renders UTC output', () => { expect(formatTimestamp(1_700_000_000n)).toBe('2023-11-14 22:13:20 UTC') diff --git a/ui/ts/tests/overviewPanels.test.tsx b/ui/ts/tests/overviewPanels.test.tsx index 77e945f2..ed0f9034 100644 --- a/ui/ts/tests/overviewPanels.test.tsx +++ b/ui/ts/tests/overviewPanels.test.tsx @@ -5,10 +5,23 @@ import { fireEvent, within } from '@testing-library/dom' import { OverviewPanels } from '../components/OverviewPanels.js' import { installDomEnvironment } from './testUtils/domEnvironment.js' import { renderIntoDocument } from './testUtils/renderIntoDocument.js' +import { act } from 'preact/test-utils' describe('OverviewPanels', () => { + type MetricElement = { + classList: { + contains: (token: string) => boolean + } + firstElementChild: MetricElement | null + getAttribute: (name: string) => string | null + parentElement: MetricElement | null + } + let restoreDomEnvironment: (() => void) | undefined let cleanupRenderedComponent: (() => Promise) | undefined + let setClientWidthResolver = (_resolver: (element: MetricElement) => number) => undefined + let setMeasureWidthResolver = (_resolver: (element: MetricElement) => number) => undefined + let triggerResizeObservers = () => undefined async function renderOverviewPanels(overrides: Partial[0]> = {}) { const baseProps: Parameters[0] = { @@ -54,11 +67,62 @@ describe('OverviewPanels', () => { beforeEach(() => { const domEnvironment = installDomEnvironment() restoreDomEnvironment = domEnvironment.cleanup + let resolveClientWidth = (_element: MetricElement) => 0 + let resolveMeasureWidth = (_element: MetricElement) => 0 + const resizeObservers: MockResizeObserver[] = [] + const originalGetBoundingClientRect = domEnvironment.window.HTMLElement.prototype.getBoundingClientRect + + Object.defineProperty(domEnvironment.window.HTMLElement.prototype, 'clientWidth', { + configurable: true, + get() { + return resolveClientWidth(this) + }, + }) + + domEnvironment.window.HTMLElement.prototype.getBoundingClientRect = function () { + if (this.classList.contains('currency-value-measure')) { + return new domEnvironment.window.DOMRect(0, 0, resolveMeasureWidth(this), 0) + } + return originalGetBoundingClientRect.call(this) + } + + class MockResizeObserver implements ResizeObserver { + callback: ResizeObserverCallback + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + resizeObservers.push(this) + } + + disconnect() {} + + observe(_target: Element, _options?: ResizeObserverOptions) {} + + unobserve(_target: Element) {} + } + + Reflect.set(globalThis, 'ResizeObserver', MockResizeObserver) + setClientWidthResolver = nextResolver => { + resolveClientWidth = nextResolver + } + setMeasureWidthResolver = nextResolver => { + resolveMeasureWidth = nextResolver + } + + triggerResizeObservers = () => { + for (const observer of resizeObservers) { + observer.callback([], observer) + } + } }) afterEach(async () => { await cleanupRenderedComponent?.() cleanupRenderedComponent = undefined + Reflect.deleteProperty(globalThis, 'ResizeObserver') + setClientWidthResolver = (_resolver: (element: MetricElement) => number) => undefined + setMeasureWidthResolver = (_resolver: (element: MetricElement) => number) => undefined + triggerResizeObservers = () => undefined restoreDomEnvironment?.() restoreDomEnvironment = undefined }) @@ -96,7 +160,7 @@ describe('OverviewPanels', () => { const documentQueries = await renderOverviewPanels({ repPerEthPrice: 2439024390243902439024n, }) - expect(documentQueries.getByText('≈ 2 439.02')).toBeDefined() + expect(documentQueries.getByTitle('2 439.024390243902439024')).toBeDefined() expect(documentQueries.queryByText(/0\.00041/)).toBeNull() }) @@ -110,4 +174,40 @@ describe('OverviewPanels', () => { expect(onRefreshRepPrices).toHaveBeenCalledTimes(1) }) + + test('compacts a large ETH balance without affecting the adjacent WETH metric', async () => { + setClientWidthResolver(element => { + if (!element.classList.contains('currency-value')) return 0 + if (element.getAttribute('title') === '999 999 990 000 ETH') return 80 + if (element.getAttribute('title') === '10 000 WETH') return 160 + return 160 + }) + + setMeasureWidthResolver(element => { + const parentTitle = element.parentElement?.firstElementChild?.getAttribute('title') + if (parentTitle === '999 999 990 000 ETH') return 180 + if (parentTitle === '10 000 WETH') return 110 + return 80 + }) + + const documentQueries = await renderOverviewPanels({ + accountState: { + address: '0x1234567890123456789012345678901234567890', + chainId: '0x1', + ethBalance: 999999990000n * 10n ** 18n, + wethBalance: 10000n * 10n ** 18n, + }, + universeRepBalance: 5n * 10n ** 18n, + }) + + await act(() => { + triggerResizeObservers() + }) + + const ethButton = documentQueries.getByRole('button', { name: 'Copy exact value 999 999 990 000' }) + const wethButton = documentQueries.getByRole('button', { name: 'Copy exact value 10 000' }) + + expect(ethButton.textContent).toBe('≈ 1T ETH') + expect(wethButton.textContent).toBe('≈ 10 000.00 WETH') + }) })