diff --git a/apps/www/src/components/playground/amount-examples.tsx b/apps/www/src/components/playground/amount-examples.tsx index 40f10fca5..f5f2e2d98 100644 --- a/apps/www/src/components/playground/amount-examples.tsx +++ b/apps/www/src/components/playground/amount-examples.tsx @@ -46,6 +46,28 @@ export function AmountExamples() { /> + + + Compact: + + + Narrow symbol:{' '} + + + + Gain: + + + Loss: + + + Proportional figures: + + ); diff --git a/apps/www/src/content/docs/components/amount/demo.ts b/apps/www/src/content/docs/components/amount/demo.ts index f98876517..16566ff47 100644 --- a/apps/www/src/content/docs/components/amount/demo.ts +++ b/apps/www/src/content/docs/components/amount/demo.ts @@ -32,9 +32,19 @@ export const playground = { }, currencyDisplay: { type: 'select', - options: ['symbol', 'code', 'name'], + options: ['symbol', 'narrowSymbol', 'code', 'name'], defaultValue: 'symbol' }, + notation: { + type: 'select', + options: ['standard', 'compact'], + defaultValue: 'standard' + }, + signDisplay: { + type: 'select', + options: ['auto', 'always', 'exceptZero', 'never'], + defaultValue: 'auto' + }, minimumFractionDigits: { type: 'number', defaultValue: undefined @@ -50,6 +60,10 @@ export const playground = { hideCurrency: { type: 'checkbox', defaultValue: false + }, + tabularNums: { + type: 'checkbox', + defaultValue: true } }, getCode @@ -123,6 +137,44 @@ export const currencyDisplayDemo = { ` }; +export const notationDemo = { + type: 'code', + code: ` + + {/* $1.2M */} + {/* $13K */} + {/* $1,200,000.00 */} + + ` +}; + +export const signDisplayDemo = { + type: 'code', + code: ` + + {/* +$12.99 */} + {/* -$12.99 */} + {/* $0.00 */} + {/* $12.99 */} + + ` +}; + +export const tabularNumsDemo = { + type: 'code', + code: ` + + {/* Tabular figures (default) keep digits aligned across rows */} + + + {/* Proportional figures read better in running text */} + + You saved today + + + ` +}; + export const hideCurrencyDemo = { type: 'code', code: ` diff --git a/apps/www/src/content/docs/components/amount/index.mdx b/apps/www/src/content/docs/components/amount/index.mdx index c166afe6d..655e3ef3e 100644 --- a/apps/www/src/content/docs/components/amount/index.mdx +++ b/apps/www/src/content/docs/components/amount/index.mdx @@ -12,6 +12,9 @@ import { localeDemo, hideDecimalsDemo, currencyDisplayDemo, + notationDemo, + signDisplayDemo, + tabularNumsDemo, hideCurrencyDemo, groupDigitsDemo, withTextDemo, @@ -62,6 +65,24 @@ Formats and displays monetary values with locale and currency support. +### notation + +Use `compact` to abbreviate large values, e.g. `$1.2M`. Handy for dashboards and summary views. Compact rounds values by design, so avoid it where the exact amount matters. + + + +### signDisplay + +Control when the `+`/`-` sign appears. Useful for showing gains and losses. + + + +### tabularNums + +Tabular (fixed-width) figures are on by default so amounts align digit-for-digit when stacked in table rows. Turn it off in running text, where proportional figures look more natural. + + + ### hideCurrency Render only the formatted number, without any currency symbol, code, or name. Locale-driven separators and decimal places are preserved. diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index 978f9ad9c..8683effaf 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -321,4 +321,118 @@ describe('Amount', () => { expect(screen.getByText('12.990')).toBeInTheDocument(); }); }); + + describe('narrowSymbol', () => { + it('renders the narrow symbol instead of the locale-prefixed one', () => { + // en-CA formats USD as "US$12.99" with 'symbol'; narrowSymbol drops the prefix. + render( + + ); + expect(screen.getByText('$12.99')).toBeInTheDocument(); + }); + + it('matches symbol output for the home locale', () => { + render(); + expect(screen.getByText('$12.99')).toBeInTheDocument(); + }); + }); + + describe('signDisplay', () => { + it('always shows the sign when signDisplay is always', () => { + render(); + expect(screen.getByText('+$12.99')).toBeInTheDocument(); + }); + + it('shows the sign except for zero when signDisplay is exceptZero', () => { + const { rerender } = render( + + ); + expect(screen.getByText('+$12.99')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('$0.00')).toBeInTheDocument(); + }); + + it('hides the sign for negative values when signDisplay is never', () => { + render(); + expect(screen.getByText('$12.99')).toBeInTheDocument(); + }); + + it('keeps the sign when hideCurrency strips the currency token', () => { + render(); + expect(screen.getByText('+12.99')).toBeInTheDocument(); + }); + }); + + describe('notation', () => { + it('renders compact notation for large values', () => { + render(); + expect(screen.getByText('$1.2M')).toBeInTheDocument(); + }); + + it('renders standard notation by default', () => { + render(); + expect(screen.getByText('$1,200,000.00')).toBeInTheDocument(); + }); + + it('rounds small values to compact defaults (no abbreviation below 1K)', () => { + // Compact notation caps fraction digits aggressively: 12.99 → "$13". + render(); + expect(screen.getByText('$13')).toBeInTheDocument(); + }); + + it('works with hideDecimals', () => { + render(); + expect(screen.getByText('$1M')).toBeInTheDocument(); + }); + + it('works with string values', () => { + render(); + expect(screen.getByText('$1.2M')).toBeInTheDocument(); + }); + + it('works with hideCurrency', () => { + render(); + expect(screen.getByText('1.2M')).toBeInTheDocument(); + }); + + it('respects explicit fraction digits', () => { + render( + + ); + expect(screen.getByText('$1.23M')).toBeInTheDocument(); + }); + }); + + describe('tabularNums', () => { + it('applies tabular figures by default', () => { + const { container } = render(); + const span = container.querySelector('span'); + expect(span?.className).toContain('tabular'); + }); + + it('omits tabular figures when tabularNums is false', () => { + const { container } = render(); + const span = container.querySelector('span'); + expect(span?.className).not.toContain('tabular'); + }); + + it('keeps custom className alongside the tabular class', () => { + const { container } = render( + + ); + const span = container.querySelector('span'); + expect(span?.className).toContain('custom-class'); + expect(span?.className).toContain('tabular'); + }); + }); }); diff --git a/packages/raystack/components/amount/amount.module.css b/packages/raystack/components/amount/amount.module.css index ec87c783d..6b4d71a10 100644 --- a/packages/raystack/components/amount/amount.module.css +++ b/packages/raystack/components/amount/amount.module.css @@ -1,3 +1,3 @@ -.amount { +.tabular { font-variant-numeric: tabular-nums; } diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index c30f1e2ac..8e850d9c8 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -52,9 +52,27 @@ export interface AmountProps extends ComponentProps<'span'> { /** * Currency display format * @default 'symbol' - * @example 'symbol' - $12.99, 'code' - USD 12.99, 'name' - 12.99 US Dollars + * @example 'symbol' - $12.99 (may show "US$" in non-US locales), 'narrowSymbol' - $12.99 (always the narrow symbol), 'code' - USD 12.99, 'name' - 12.99 US Dollars */ - currencyDisplay?: 'symbol' | 'code' | 'name'; + currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name'; + + /** + * Number formatting notation. + * 'compact' abbreviates large values — useful for dashboards and summary views. + * Compact rounds aggressively by design; avoid it when the value must render exactly. + * @default 'standard' + * @example + * 'standard' - $1,200,000.00, 'compact' - $1.2M + */ + notation?: 'standard' | 'compact'; + + /** + * When to show the +/- sign — useful for gains/losses. + * @default 'auto' + * @example + * 'auto' - -$12.99, 'always' - +$12.99, 'exceptZero' - +$12.99 but $0.00, 'never' - $12.99 + */ + signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never'; /** * Number of minimum fraction digits @@ -83,41 +101,76 @@ export interface AmountProps extends ComponentProps<'span'> { * => "12.99" */ hideCurrency?: boolean; + + /** + * Use fixed-width (tabular) figures so digits align vertically across rows — + * ideal for tables and lists of amounts. Set to false in running text, + * where proportional figures look more natural. + * @default true + */ + tabularNums?: boolean; } /** - * Get the number of decimal places for a currency + * Intl.NumberFormat construction is expensive, and Amount often renders + * hundreds of times in a table. Cache instances module-wide, keyed by + * locale + options. The cap guards against unbounded growth when + * locales/currencies are dynamic. */ -function getCurrencyDecimals(currency: string): number { - try { - const formatter = new Intl.NumberFormat('en', { - style: 'currency', - currency: currency.toUpperCase() - }); +const FORMATTER_CACHE_LIMIT = 64; +const formatterCache = new Map(); - // Format a number and count the decimal places - const formatted = formatter.format(1); // Get string representation of 1 unit with currency symbol - const match = formatted.match(/\.([\d]+)/); // Extract the decimal part - return match ? match[1].length : 0; - } catch { - // Default to 2 decimal places - return 2; +function getFormatter( + locale: string, + options: Intl.NumberFormatOptions +): Intl.NumberFormat { + const key = `${locale}|${JSON.stringify(options)}`; + let formatter = formatterCache.get(key); + if (!formatter) { + formatter = new Intl.NumberFormat(locale, options); + if (formatterCache.size >= FORMATTER_CACHE_LIMIT) formatterCache.clear(); + formatterCache.set(key, formatter); } + return formatter; +} + +interface CurrencyInfo { + valid: boolean; + decimals: number; } /** - * Check if a currency is valid + * Sized to hold every valid ISO 4217 code (~180) without eviction; + * the cap only guards against unbounded growth from invalid inputs. */ -function isValidCurrency(currency: string): boolean { - try { - new Intl.NumberFormat('en', { - style: 'currency', - currency: currency.toUpperCase() - }); - return true; - } catch { - return false; +const CURRENCY_INFO_CACHE_LIMIT = 256; +const currencyInfoCache = new Map(); + +/** + * Resolve a currency's validity and decimal places in one cached lookup. + */ +function getCurrencyInfo(currency: string): CurrencyInfo { + const code = currency.toUpperCase(); + let info = currencyInfoCache.get(code); + if (!info) { + try { + const formatter = getFormatter('en', { + style: 'currency', + currency: code + }); + info = { + valid: true, + decimals: formatter.resolvedOptions().maximumFractionDigits ?? 2 + }; + } catch { + info = { valid: false, decimals: 2 }; + } + if (currencyInfoCache.size >= CURRENCY_INFO_CACHE_LIMIT) { + currencyInfoCache.clear(); + } + currencyInfoCache.set(code, info); } + return info; } /** @@ -152,9 +205,14 @@ function isValidCurrency(currency: string): boolean { * Amount: // Shows as "$12.99" * * - * // With groupDigits (default is true) + * // Compact notation for dashboards + * + * Revenue: // Shows as "$1.2M" + * + * + * // Signed amounts for gains/losses * - * Amount: // Shows as "$129,999,999.99" + * Change: // Shows as "+$12.99" * * ``` */ @@ -164,11 +222,14 @@ export const Amount = ({ locale = 'en-US', hideDecimals = false, currencyDisplay = 'symbol', + notation = 'standard', + signDisplay = 'auto', minimumFractionDigits, maximumFractionDigits, groupDigits = true, valueInMinorUnits = true, hideCurrency = false, + tabularNums = true, className, ...props }: AmountProps) => { @@ -183,12 +244,15 @@ export const Amount = ({ ); } - const validCurrency = isValidCurrency(currency) ? currency : 'USD'; - if (validCurrency !== currency) { + const currencyInfo = getCurrencyInfo(currency); + const validCurrency = currencyInfo.valid ? currency : 'USD'; + if (!currencyInfo.valid) { console.warn(`Invalid currency code: ${currency}. Falling back to USD.`); } - const decimals = getCurrencyDecimals(validCurrency); + const decimals = currencyInfo.valid + ? currencyInfo.decimals + : getCurrencyInfo('USD').decimals; /** * Convert minor → major units. @@ -240,12 +304,14 @@ export const Amount = ({ style: 'currency', currency: validCurrency.toUpperCase(), currencyDisplay, + notation, + signDisplay, minimumFractionDigits: hideDecimals ? 0 : minimumFractionDigits, maximumFractionDigits: hideDecimals ? 0 : maximumFractionDigits, useGrouping: groupDigits }; - const formatter = new Intl.NumberFormat(locale, formatOptions); + const formatter = getFormatter(locale, formatOptions); /** * For hideCurrency, strip the `currency` parts and trim leading/trailing @@ -269,14 +335,14 @@ export const Amount = ({ ); return ( - + {formattedValue} ); } catch (error) { console.error('Error formatting amount:', error); return ( - + {String(value)} );