diff --git a/apps/www/src/content/docs/components/amount/demo.ts b/apps/www/src/content/docs/components/amount/demo.ts index 528cd8bd1..3c12b4314 100644 --- a/apps/www/src/content/docs/components/amount/demo.ts +++ b/apps/www/src/content/docs/components/amount/demo.ts @@ -46,6 +46,10 @@ export const playground = { groupDigits: { type: 'checkbox', defaultValue: true + }, + hideCurrency: { + type: 'checkbox', + defaultValue: false } }, getCode @@ -119,6 +123,17 @@ export const currencyDisplayDemo = { ` }; +export const hideCurrencyDemo = { + type: 'code', + code: ` + + {/* 12.99 */} + {/* 1,299 */} + {/* 12.99 — currencyDisplay is ignored */} + + ` +}; + export const groupDigitsDemo = { type: 'code', code: ` @@ -149,13 +164,24 @@ export const withTextDemo = { export const largeNumbersDemo = { type: 'code', code: ` - - {/* For large numbers, use string to maintain precision */} + + {/* + For large numbers, use string (supports decimals) or bigint (integer-only) + to maintain precision + */} {/* $9,999,999,999,999.99 */} - {/* $10,000,100,091,636,935 */} - - {/* Numbers exceeding safe integer limit will show warning in console */} - {/* Will show warning */} + {/* $10,000,100,091,636,935 */} + + {/* + BigInt is always treated as major units — 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 */} ` }; diff --git a/apps/www/src/content/docs/components/amount/index.mdx b/apps/www/src/content/docs/components/amount/index.mdx index efc8d66b4..c166afe6d 100644 --- a/apps/www/src/content/docs/components/amount/index.mdx +++ b/apps/www/src/content/docs/components/amount/index.mdx @@ -12,6 +12,7 @@ import { localeDemo, hideDecimalsDemo, currencyDisplayDemo, + hideCurrencyDemo, groupDigitsDemo, withTextDemo, largeNumbersDemo, @@ -61,13 +62,19 @@ Formats and displays monetary values with locale and currency support. +### hideCurrency + +Render only the formatted number, without any currency symbol, code, or name. Locale-driven separators and decimal places are preserved. + + + ### groupDigits ### Large Numbers -For numbers larger than JavaScript's safe integer limit (2^53 - 1), pass the value as a string to maintain precision. +For numbers larger than JavaScript's safe integer limit (2^53 - 1), pass the value as a `string` (supports decimals) or a `bigint` (integer-only). BigInt values are always treated as already in major units, so `valueInMinorUnits` is ignored for them. diff --git a/apps/www/src/content/docs/components/amount/props.ts b/apps/www/src/content/docs/components/amount/props.ts index c6a7fb9e6..5ec8a654b 100644 --- a/apps/www/src/content/docs/components/amount/props.ts +++ b/apps/www/src/content/docs/components/amount/props.ts @@ -1,14 +1,18 @@ export interface AmountProps { /** - * The monetary value to display - * For large numbers (> 2^53), pass the value as string to maintain precision + * The monetary value to display. + * For exact precision beyond 2^53, pass either: + * - a `string` — supports decimals (e.g. "1299" or "12.99") + * - a `bigint` — integer-only; treated as already in major units, so + * `valueInMinorUnits` is ignored when value is a bigint * @default 0 * @example * valueInMinorUnits=true: 1299 => "$12.99" * valueInMinorUnits=false: 12.99 => "$12.99" - * Large numbers: "999999999999999" => "$9,999,999,999,999.99" + * Large strings: "999999999999999" => "$9,999,999,999,999.99" + * BigInt: 1299n => "$1,299.00" (always major units) */ - value: number | string; + value: number | string | bigint; /** * ISO 4217 currency code @@ -65,4 +69,14 @@ export interface AmountProps { * @default true */ groupDigits?: boolean; + + /** + * Render the formatted number without a currency symbol, code, or name. + * Locale-driven separators, grouping, and fraction digits are preserved. + * When true, `currencyDisplay` is ignored. + * @default false + * @example + * => "12.99" + */ + hideCurrency?: boolean; } diff --git a/packages/raystack/components/amount/__tests__/amount.test.tsx b/packages/raystack/components/amount/__tests__/amount.test.tsx index 65c2aa712..bbc804afc 100644 --- a/packages/raystack/components/amount/__tests__/amount.test.tsx +++ b/packages/raystack/components/amount/__tests__/amount.test.tsx @@ -203,5 +203,125 @@ describe('Amount', () => { expect(element).toBeInTheDocument(); consoleSpy.mockRestore(); }); + + it('handles negative string values in minor units', () => { + render(); + expect(screen.getByText('-$12.99')).toBeInTheDocument(); + }); + + it('warns when a string longer than 15 digits is formatted', () => { + // The component flags potential precision loss for any >15-digit string, + // letting the developer decide whether their runtime targets are at risk + // and whether to switch to bigint. + const consoleSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => null); + render(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('longer than 15 digits') + ); + consoleSpy.mockRestore(); + }); + }); + + describe('BigInt support', () => { + it('formats a bigint as major units regardless of valueInMinorUnits', () => { + render(); + expect(screen.getByText('$1,299.00')).toBeInTheDocument(); + }); + + it('matches the major-units result when valueInMinorUnits is false', () => { + render(); + expect(screen.getByText('$1,299.00')).toBeInTheDocument(); + }); + + it('preserves precision beyond Number.MAX_SAFE_INTEGER', () => { + render(); + expect( + screen.getByText('$9,999,999,999,999,999,999.00') + ).toBeInTheDocument(); + }); + + it('formats negative bigint values', () => { + render(); + expect(screen.getByText('-$1,299.00')).toBeInTheDocument(); + }); + + it('formats bigint with a zero-decimal currency', () => { + render(); + expect(screen.getByText('¥1,299')).toBeInTheDocument(); + }); + + it('does not warn about safe integer limit for bigint values', () => { + const consoleSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => null); + render(); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('hideCurrency', () => { + it('hides the currency symbol while preserving formatting', () => { + render(); + expect(screen.getByText('12.99')).toBeInTheDocument(); + }); + + it('preserves trailing zeros for round amounts (USD)', () => { + render(); + expect(screen.getByText('12.00')).toBeInTheDocument(); + }); + + it('preserves currency fraction digits for round bigint values', () => { + render(); + expect(screen.getByText('1,299.00')).toBeInTheDocument(); + }); + + it('preserves currency fraction digits for a 3-decimal currency (BHD)', () => { + render(); + expect(screen.getByText('1.234')).toBeInTheDocument(); + }); + + it('overrides currencyDisplay when set', () => { + render(); + expect(screen.getByText('12.99')).toBeInTheDocument(); + }); + + it('respects the currency for decimal-place math even when hidden', () => { + render(); + expect(screen.getByText('1,299')).toBeInTheDocument(); + }); + + it('honors explicit minimumFractionDigits over the currency default', () => { + render( + + ); + expect(screen.getByText('12.0000')).toBeInTheDocument(); + }); + + it('hideDecimals wins over the currency-default fraction digits', () => { + render(); + expect(screen.getByText('12')).toBeInTheDocument(); + }); + + it('clamps min when only maximumFractionDigits is provided (avoids RangeError)', () => { + // USD's default min of 2 would invert against max=1; with style:'currency' + // + formatToParts(), Intl's spec auto-clamps min → 1, so 12.99 rounds to 13.0. + render(); + expect(screen.getByText('13.0')).toBeInTheDocument(); + }); + + it('clamps max when only minimumFractionDigits is provided (avoids RangeError)', () => { + // USD's default max of 2 would invert against min=3; with style:'currency' + // + formatToParts(), Intl's spec auto-clamps max → 3, so 12.99 pads to 12.990. + render(); + expect(screen.getByText('12.990')).toBeInTheDocument(); + }); }); }); diff --git a/packages/raystack/components/amount/amount.tsx b/packages/raystack/components/amount/amount.tsx index 8ee095e1f..28813109f 100644 --- a/packages/raystack/components/amount/amount.tsx +++ b/packages/raystack/components/amount/amount.tsx @@ -2,14 +2,18 @@ import { type ComponentProps } from 'react'; export interface AmountProps extends ComponentProps<'span'> { /** - * The monetary value to display - * For large numbers (> 2^53), pass the value as string to maintain precision + * The monetary value to display. + * For exact precision beyond 2^53, pass either: + * - a `string` — supports decimals (e.g. "1299" or "12.99") + * - a `bigint` — integer-only; treated as already in major units, so + * `valueInMinorUnits` is ignored when value is a bigint * @default 0 * @example * valueInMinorUnits=true: 1299 => "$12.99" * valueInMinorUnits=false: 12.99 => "$12.99" + * bigint: 1299n => "$1,299.00" (always major units) */ - value: number | string; + value: number | string | bigint; /** * ISO 4217 currency code @@ -66,6 +70,16 @@ export interface AmountProps extends ComponentProps<'span'> { * @default true */ groupDigits?: boolean; + + /** + * Render the formatted number without a currency symbol, code, or name. + * Locale-driven separators, grouping, and fraction digits are preserved. + * When true, `currencyDisplay` is ignored. + * @default false + * @example + * => "12.99" + */ + hideCurrency?: boolean; } /** @@ -151,6 +165,7 @@ export const Amount = ({ maximumFractionDigits, groupDigits = true, valueInMinorUnits = true, + hideCurrency = false, ...props }: AmountProps) => { try { @@ -160,7 +175,7 @@ export const Amount = ({ ) { console.warn( `Warning: The number ${value} exceeds JavaScript's safe integer limit (${Number.MAX_SAFE_INTEGER}). ` + - 'For large numbers, pass the value as a string to maintain precision.' + 'For large numbers, pass the value as a bigint or string to maintain precision.' ); } @@ -171,37 +186,100 @@ export const Amount = ({ const decimals = getCurrencyDecimals(validCurrency); - // Handle minor units - use string manipulation for strings and Math.pow for numbers - const baseValue = - valueInMinorUnits && decimals > 0 - ? typeof value === 'string' - ? value.slice(0, -decimals) + '.' + value.slice(-decimals) - : value / Math.pow(10, decimals) - : value; - - // Remove decimals if hideDecimals is true - handle string and number separately - // Note: Not all numbers passed is converted to string as methods like Math.trunc - // or toString cannot handle large numbers thus, we need to handle it separately (large numbers passed in value throws console warning). - const finalBaseValue = hideDecimals - ? typeof baseValue === 'string' - ? baseValue.split('.')[0] - : Math.trunc(baseValue) - : baseValue; - - const formattedValue = new Intl.NumberFormat(locale, { - style: 'currency' as const, + /** + * Convert minor → major units. + * Three input shapes: bigint, string, number. + * BigInt is always treated as already in major units (it cannot represent fractions), + * so `valueInMinorUnits` is ignored for BigInt. + */ + let baseValue: number | string | bigint; + if (typeof value === 'bigint') { + baseValue = value; + } else if (valueInMinorUnits && decimals > 0) { + if (typeof value === 'string') { + const isNegative = value.startsWith('-'); + const digits = isNegative ? value.slice(1) : value; + const padded = digits.padStart(decimals + 1, '0'); + const major = padded.slice(0, -decimals); + const minor = padded.slice(-decimals); + baseValue = `${isNegative ? '-' : ''}${major}.${minor}`; + } else { + baseValue = value / Math.pow(10, decimals); + } + } else { + baseValue = value; + } + + // Remove decimals when hideDecimals is true. BigInt has no decimals, so it's a no-op there. + const finalBaseValue: number | string | bigint = !hideDecimals + ? baseValue + : typeof baseValue === 'bigint' + ? baseValue + : typeof baseValue === 'string' + ? baseValue.split('.')[0] + : Math.trunc(baseValue); + + /** + * Always format in currency mode — Intl's currency-style handles fraction digits per the currency, + * locale-correct grouping/separators, + * and auto-clamps when only one of min/max is user-provided. + * For hideCurrency, we then strip the currency token from the output via formatToParts(), + * which avoids the divergent defaults of style: 'decimal'. + */ + const formatOptions: Intl.NumberFormatOptions = { + style: 'currency', currency: validCurrency.toUpperCase(), currencyDisplay, minimumFractionDigits: hideDecimals ? 0 : minimumFractionDigits, maximumFractionDigits: hideDecimals ? 0 : maximumFractionDigits, useGrouping: groupDigits - // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`. - } as Intl.NumberFormatOptions).format(finalBaseValue); + }; + + /** + * Only flag strings whose digit count exceeds 15 — Number.MAX_SAFE_INTEGER + * is 9,007,199,254,740,991 (16 digits), so ≤15-digit strings round-trip + * through Number() without precision loss even on non-V3 runtimes. + */ + if ( + typeof finalBaseValue === 'string' && + finalBaseValue.replace(/\D/g, '').length > 15 + ) { + console.warn( + 'Amount: a string longer than 15 digits is being formatted. ' + + 'Older runtimes without Intl.NumberFormat V3 string-precision support ' + + '(Chrome <106, Firefox <116, Safari <15.4, Node <19) coerce strings via ' + + 'Number() first, which loses precision beyond 2^53. Pass a bigint for ' + + 'exact integer formatting if your targets include those runtimes.' + ); + } + + const formatter = new Intl.NumberFormat(locale, formatOptions); + + /** + * For hideCurrency, strip the `currency` parts and trim leading/trailing + * whitespace that locales like de-DE leave behind + * (e.g. "1.234,56 €" becomes "1.234,56 " before the trim). + * Otherwise format directly. + */ + const formattedValue: string = hideCurrency + ? formatter + .formatToParts( + // @ts-expect-error TS lib types omit `string` from formatToParts() params, but Intl accepts numeric strings at runtime. + finalBaseValue + ) + .filter(p => p.type !== 'currency') + .map(p => p.value) + .join('') + .trim() + : formatter.format( + // @ts-expect-error TS lib types omit `string` from format() params, but Intl.NumberFormat accepts numeric strings at runtime — needed for large values that would lose precision as `number`. + finalBaseValue + ); return {formattedValue}; } catch (error) { console.error('Error formatting amount:', error); - return {value}; + return {String(value)}; } };