From 9a2aadf1487bbfc180c3b8ae626efa7e66bdf6ba Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Fri, 31 Jul 2026 17:29:54 +0530 Subject: [PATCH 1/4] feat: improve Amount formatting capabilities (#595) - Add notation prop for compact formatting ($1.2M) in dashboards - Add narrowSymbol option to currencyDisplay - Add signDisplay prop for gains/losses (+/- sign control) - Add tabularNums prop (default true) to toggle fixed-width figures - Memoize Intl.NumberFormat instances module-wide; resolve currency validity and decimals in one cached lookup --- .../components/playground/amount-examples.tsx | 22 +++ .../content/docs/components/amount/demo.ts | 54 +++++++- .../content/docs/components/amount/index.mdx | 21 +++ .../amount/__tests__/amount.test.tsx | 114 ++++++++++++++++ .../components/amount/amount.module.css | 2 +- .../raystack/components/amount/amount.tsx | 126 +++++++++++++----- 6 files changed, 303 insertions(+), 36 deletions(-) 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..62a586820 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,68 @@ 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; } +const currencyInfoCache = new Map(); + /** - * Check if a currency is valid + * Resolve a currency's validity and decimal places in one cached lookup. */ -function isValidCurrency(currency: string): boolean { - try { - new Intl.NumberFormat('en', { - style: 'currency', - currency: currency.toUpperCase() - }); - return true; - } catch { - return false; +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 }; + } + currencyInfoCache.set(code, info); } + return info; } /** @@ -152,9 +197,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 +214,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 +236,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 +296,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 +327,14 @@ export const Amount = ({ ); return ( - + {formattedValue} ); } catch (error) { console.error('Error formatting amount:', error); return ( - + {String(value)} ); From bd163586c0e0eae9e169a6de472754e07cfee89c Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Fri, 31 Jul 2026 18:08:20 +0530 Subject: [PATCH 2/4] fix: bound currencyInfoCache to prevent unbounded growth --- packages/raystack/components/amount/amount.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 62a586820..8e850d9c8 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -139,6 +139,11 @@ interface CurrencyInfo { decimals: number; } +/** + * Sized to hold every valid ISO 4217 code (~180) without eviction; + * the cap only guards against unbounded growth from invalid inputs. + */ +const CURRENCY_INFO_CACHE_LIMIT = 256; const currencyInfoCache = new Map(); /** @@ -160,6 +165,9 @@ function getCurrencyInfo(currency: string): CurrencyInfo { } catch { info = { valid: false, decimals: 2 }; } + if (currencyInfoCache.size >= CURRENCY_INFO_CACHE_LIMIT) { + currencyInfoCache.clear(); + } currencyInfoCache.set(code, info); } return info; From cdeaec16adbe9b539b6a586cad280b3f6eb4af60 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 13:44:06 +0530 Subject: [PATCH 3/4] fix: [amount] keep the usd fallback and set proportional figures - Fall back to USD when currency is not a string. The lookup called toUpperCase outside its try, so null rendered the raw value. - Set proportional-nums when tabularNums is false, so a tabular parent such as Table or DataTable does not override it. - Read currency decimals with a plain Intl.NumberFormat, so the lookup does not use a slot in the formatter cache. - Pin the short string case from #595 and use the CSS module in the tabularNums tests. - Document narrowSymbol, and bring the new JSDoc and docs in line with the writing style rules. --- .../content/docs/components/amount/demo.ts | 8 ++- .../content/docs/components/amount/index.mdx | 8 +-- .../amount/__tests__/amount.test.tsx | 57 ++++++++++++----- .../components/amount/amount.module.css | 4 ++ .../raystack/components/amount/amount.tsx | 61 ++++++++----------- 5 files changed, 80 insertions(+), 58 deletions(-) diff --git a/apps/www/src/content/docs/components/amount/demo.ts b/apps/www/src/content/docs/components/amount/demo.ts index 16566ff47..09fec0d56 100644 --- a/apps/www/src/content/docs/components/amount/demo.ts +++ b/apps/www/src/content/docs/components/amount/demo.ts @@ -131,6 +131,8 @@ export const currencyDisplayDemo = { code: ` {/* $12.99 */} + {/* US$12.99 */} + {/* $12.99 */} {/* USD 12.99 */} {/* 12.99 US dollars */} @@ -181,7 +183,7 @@ export const hideCurrencyDemo = { {/* 12.99 */} {/* 1,299 */} - {/* 12.99 — currencyDisplay is ignored */} + {/* 12.99 (currencyDisplay is ignored) */} ` }; @@ -226,14 +228,14 @@ export const largeNumbersDemo = { valueInMinorUnits={false} hideDecimals />{/* $10,000,100,091,636,935 */} {/* - BigInt is always treated as major units — valueInMinorUnits is ignored + BigInt is always treated as major units, so valueInMinorUnits is ignored */} {/* $9,999,999,999,999,999,999.00 */} {/* Numbers exceeding safe integer limit will show warning in console */} - {/* Exceeds Number.MAX_SAFE_INTEGER (~9 × 10^15) — logs a console warning */} + {/* Exceeds Number.MAX_SAFE_INTEGER (~9 × 10^15), so it logs a console warning */} ` }; diff --git a/apps/www/src/content/docs/components/amount/index.mdx b/apps/www/src/content/docs/components/amount/index.mdx index cd57c4c44..30cc9e8fe 100644 --- a/apps/www/src/content/docs/components/amount/index.mdx +++ b/apps/www/src/content/docs/components/amount/index.mdx @@ -51,25 +51,25 @@ Pass a `value` and a `currency` code. Amount formats it for the active locale us ### Currency display -How the currency is written: `symbol` (the default, `$`), `code` (`USD`), or `name` (`US dollars`). +How the currency is written: `symbol` (the default, `$`), `narrowSymbol` (`$`, even in locales where `symbol` shows `US$`), `code` (`USD`), or `name` (`US dollars`). ### Compact 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. +Set `notation="compact"` to abbreviate large values in dashboards and summary views, for example `$1.2M`. Compact notation rounds, so do not use it where the exact amount matters. ### Sign display -Control when the `+`/`-` sign appears. Useful for showing gains and losses. +`signDisplay` sets when the `+` or `-` sign shows. Use `always` to show gains and losses as `+$12.99` and `-$12.99`. ### Tabular numbers -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. +Amount uses tabular (fixed-width) figures by default, so digits align across table rows. Set `tabularNums={false}` in running text to use proportional figures. diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index 8683effaf..5fcd0cb68 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { Amount } from '../amount'; +import styles from '../amount.module.css'; describe('Amount', () => { describe('Basic Rendering', () => { @@ -98,6 +99,19 @@ describe('Amount', () => { consoleSpy.mockRestore(); }); + it('falls back to USD when the currency is not a string', () => { + const consoleSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => null); + // API data can send null even though the prop type is string. + render(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Invalid currency code: null. Falling back to USD.' + ); + expect(screen.getByText('$12.99')).toBeInTheDocument(); + consoleSpy.mockRestore(); + }); + it('handles lowercase currency codes', () => { render(); expect(screen.getByText('€12.99')).toBeInTheDocument(); @@ -224,6 +238,21 @@ describe('Amount', () => { render(); expect(screen.getByText('-$12.99')).toBeInTheDocument(); }); + + it('pads string values shorter than the currency decimals', () => { + render(); + expect(screen.getByText('$0.05')).toBeInTheDocument(); + }); + + it('pads negative string values shorter than the currency decimals', () => { + render(); + expect(screen.getByText('-$0.05')).toBeInTheDocument(); + }); + + it('pads short string values for a 3-decimal currency', () => { + render(); + expect(screen.getByText('0.005')).toBeInTheDocument(); + }); }); describe('BigInt support', () => { @@ -380,7 +409,7 @@ describe('Amount', () => { }); it('rounds small values to compact defaults (no abbreviation below 1K)', () => { - // Compact notation caps fraction digits aggressively: 12.99 → "$13". + // Compact notation keeps 2 significant digits by default. render(); expect(screen.getByText('$13')).toBeInTheDocument(); }); @@ -415,24 +444,24 @@ describe('Amount', () => { describe('tabularNums', () => { it('applies tabular figures by default', () => { - const { container } = render(); - const span = container.querySelector('span'); - expect(span?.className).toContain('tabular'); + render(); + const amount = screen.getByText('$12.99'); + expect(amount).toHaveClass(styles.tabular); + expect(amount).not.toHaveClass(styles.proportional); }); - it('omits tabular figures when tabularNums is false', () => { - const { container } = render(); - const span = container.querySelector('span'); - expect(span?.className).not.toContain('tabular'); + it('applies proportional figures when tabularNums is false', () => { + render(); + const amount = screen.getByText('$12.99'); + expect(amount).toHaveClass(styles.proportional); + expect(amount).not.toHaveClass(styles.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'); + render(); + const amount = screen.getByText('$12.99'); + expect(amount).toHaveClass('custom-class'); + expect(amount).toHaveClass(styles.tabular); }); }); }); diff --git a/packages/raystack/components/amount/amount.module.css b/packages/raystack/components/amount/amount.module.css index 6b4d71a10..cb503ea8d 100644 --- a/packages/raystack/components/amount/amount.module.css +++ b/packages/raystack/components/amount/amount.module.css @@ -1,3 +1,7 @@ .tabular { font-variant-numeric: tabular-nums; } + +.proportional { + font-variant-numeric: proportional-nums; +} diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 374d35494..e8774b1b6 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -50,27 +50,20 @@ export interface AmountProps extends ComponentProps<'span'> { hideDecimals?: boolean; /** - * Currency display format + * How the currency is written. `narrowSymbol` shows `$` where `symbol` shows `US$`. * @default 'symbol' - * @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' | '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. + * Number notation. `compact` abbreviates and rounds large values, for example `$1.2M`. * @default 'standard' - * @example - * 'standard' - $1,200,000.00, 'compact' - $1.2M */ notation?: 'standard' | 'compact'; /** - * When to show the +/- sign — useful for gains/losses. + * When to show the `+` or `-` sign. * @default 'auto' - * @example - * 'auto' - -$12.99, 'always' - +$12.99, 'exceptZero' - +$12.99 but $0.00, 'never' - $12.99 */ signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never'; @@ -103,19 +96,15 @@ export interface AmountProps extends ComponentProps<'span'> { 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. + * Uses fixed-width figures so digits align across rows. `false` uses proportional figures. * @default true */ tabularNums?: boolean; } /** - * 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. + * Creating an Intl.NumberFormat is slow, and a table can render hundreds of + * amounts. The cap bounds memory when locales or currencies are dynamic. */ const FORMATTER_CACHE_LIMIT = 64; const formatterCache = new Map(); @@ -140,35 +129,29 @@ interface CurrencyInfo { } /** - * Sized to hold every valid ISO 4217 code (~180) without eviction; - * the cap only guards against unbounded growth from invalid inputs. + * The cap holds every ISO 4217 code (about 180) and bounds growth from + * invalid codes. */ 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); + let info = currencyInfoCache.get(currency); if (!info) { try { - const formatter = getFormatter('en', { + const { maximumFractionDigits } = new Intl.NumberFormat('en', { style: 'currency', - currency: code - }); - info = { - valid: true, - decimals: formatter.resolvedOptions().maximumFractionDigits ?? 2 - }; + currency + }).resolvedOptions(); + info = { valid: true, decimals: maximumFractionDigits ?? 2 }; } catch { + // Invalid codes fall back to USD, which has 2 decimals. info = { valid: false, decimals: 2 }; } if (currencyInfoCache.size >= CURRENCY_INFO_CACHE_LIMIT) { currencyInfoCache.clear(); } - currencyInfoCache.set(code, info); + currencyInfoCache.set(currency, info); } return info; } @@ -250,9 +233,7 @@ export const Amount = ({ console.warn(`Invalid currency code: ${currency}. Falling back to USD.`); } - const decimals = currencyInfo.valid - ? currencyInfo.decimals - : getCurrencyInfo('USD').decimals; + const { decimals } = currencyInfo; /** * Convert minor → major units. @@ -338,7 +319,10 @@ export const Amount = ({ {formattedValue} @@ -349,7 +333,10 @@ export const Amount = ({ {String(value)} From e5a38f762e0d89d506141e18cbdc0492a740b6dd Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Fri, 25 Sep 2026 13:57:08 +0530 Subject: [PATCH 4/4] fix: [amount] drop the sign on truncated zero and document hidedecimals - hideDecimals turned values between -1 and 0 into -0, which printed "-$0". Both the number and the string path now print "$0". - Document that hideDecimals truncates, and that compact notation rounds the abbreviated value instead. Pin both with tests. --- .../content/docs/components/amount/index.mdx | 4 ++-- .../amount/__tests__/amount.test.tsx | 21 ++++++++++++++++--- .../raystack/components/amount/amount.tsx | 9 ++++---- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/www/src/content/docs/components/amount/index.mdx b/apps/www/src/content/docs/components/amount/index.mdx index 30cc9e8fe..09bb6a979 100644 --- a/apps/www/src/content/docs/components/amount/index.mdx +++ b/apps/www/src/content/docs/components/amount/index.mdx @@ -57,7 +57,7 @@ How the currency is written: `symbol` (the default, `$`), `narrowSymbol` (`$`, e ### Compact notation -Set `notation="compact"` to abbreviate large values in dashboards and summary views, for example `$1.2M`. Compact notation rounds, so do not use it where the exact amount matters. +Set `notation="compact"` to abbreviate large values in dashboards and summary views, for example `$1.2M`. Compact notation rounds, so do not use it where the exact amount matters. With `hideDecimals`, it rounds to a whole number, so `$1.55M` shows as `$2M`. @@ -93,7 +93,7 @@ Set `valueInMinorUnits` when your API returns integer cents, paise, or fils. Amo ### Whole units -`hideDecimals` rounds to whole units. Use it in dense tables and summary figures where the fraction adds noise rather than precision. +`hideDecimals` truncates to whole units, so `$12.99` shows as `$12`. Use it in dense tables and summary figures where the fraction adds noise rather than precision. diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index 5fcd0cb68..26765f75b 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -146,6 +146,21 @@ describe('Amount', () => { expect(screen.getByText('$12')).toBeInTheDocument(); }); + it('drops the sign when hideDecimals truncates a negative value to zero', () => { + render(); + expect(screen.getByText('$0')).toBeInTheDocument(); + }); + + it('drops the sign when hideDecimals truncates a negative string to zero', () => { + render(); + expect(screen.getByText('$0')).toBeInTheDocument(); + }); + + it('keeps the sign when hideDecimals truncates a value below -1', () => { + render(); + expect(screen.getByText('-$12')).toBeInTheDocument(); + }); + it('displays currency as symbol by default', () => { render(); expect(screen.getByText('$12.99')).toBeInTheDocument(); @@ -414,9 +429,9 @@ describe('Amount', () => { expect(screen.getByText('$13')).toBeInTheDocument(); }); - it('works with hideDecimals', () => { - render(); - expect(screen.getByText('$1M')).toBeInTheDocument(); + it('rounds the abbreviated value with hideDecimals', () => { + render(); + expect(screen.getByText('$2M')).toBeInTheDocument(); }); it('works with string values', () => { diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index e8774b1b6..30544b8b1 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -44,7 +44,7 @@ export interface AmountProps extends ComponentProps<'span'> { locale?: string; /** - * Truncates decimal places + * Truncates to whole units. With `compact` notation, it rounds the abbreviated value instead. * @default false */ hideDecimals?: boolean; @@ -265,14 +265,15 @@ export const Amount = ({ baseValue = value; } - // Remove decimals when hideDecimals is true. BigInt has no decimals, so it's a no-op there. + // BigInt has no decimals. Truncating a value between -1 and 0 gives -0, + // which formats as "-$0", so both paths drop that sign (`+ 0` turns -0 into 0). const finalBaseValue: number | string | bigint = !hideDecimals ? baseValue : typeof baseValue === 'bigint' ? baseValue : typeof baseValue === 'string' - ? baseValue.split('.')[0] - : Math.trunc(baseValue); + ? baseValue.split('.')[0].replace(/^-0+$/, '0') + : Math.trunc(baseValue) + 0; /** * Always format in currency mode, since Intl's currency-style handles fraction digits per the currency,