Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions apps/www/src/content/docs/components/amount/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export const playground = {
groupDigits: {
type: 'checkbox',
defaultValue: true
},
hideCurrency: {
type: 'checkbox',
defaultValue: false
}
},
getCode
Expand Down Expand Up @@ -119,6 +123,17 @@ export const currencyDisplayDemo = {
`
};

export const hideCurrencyDemo = {
type: 'code',
code: `
<Flex gap={4}>
<Amount value={1299} hideCurrency /> {/* 12.99 */}
<Amount value={1299} currency="JPY" hideCurrency /> {/* 1,299 */}
<Amount value={1299} hideCurrency currencyDisplay="code" />{/* 12.99 — currencyDisplay is ignored */}
</Flex>
`
};

export const groupDigitsDemo = {
type: 'code',
code: `
Expand Down Expand Up @@ -149,13 +164,24 @@ export const withTextDemo = {
export const largeNumbersDemo = {
type: 'code',
code: `
<Flex gap={4}>
{/* For large numbers, use string to maintain precision */}
<Flex direction='column' gap={4}>
{/*
For large numbers, use string (supports decimals) or bigint (integer-only)
to maintain precision
*/}
<Amount value="999999999999999" /> {/* $9,999,999,999,999.99 */}
<Amount value="10000100091636935" valueInMinorUnits={false} hideDecimals /> {/* $10,000,100,091,636,935 */}

{/* Numbers exceeding safe integer limit will show warning in console */}
<Amount value={999999999999999} /> {/* Will show warning */}
<Amount value="10000100091636935"
valueInMinorUnits={false} hideDecimals />{/* $10,000,100,091,636,935 */}

{/*
BigInt is always treated as major units — valueInMinorUnits is ignored
*/}
<Amount value={BigInt("9999999999999999999")} valueInMinorUnits={false} />{/* $9,999,999,999,999,999,999.00 */}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valueInMinorUnits={false} can be removed from here as its contradicting. valueInMinorUnits is ignored if large value is passed to value prop.


{/*
Numbers exceeding safe integer limit will show warning in console
*/}
<Amount value={99999999999999999} />{/* Exceeds Number.MAX_SAFE_INTEGER (~9 × 10^15) — logs a console warning */}
</Flex>
`
};
9 changes: 8 additions & 1 deletion apps/www/src/content/docs/components/amount/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
localeDemo,
hideDecimalsDemo,
currencyDisplayDemo,
hideCurrencyDemo,
groupDigitsDemo,
withTextDemo,
largeNumbersDemo,
Expand Down Expand Up @@ -61,13 +62,19 @@ Formats and displays monetary values with locale and currency support.

<Demo data={currencyDisplayDemo} />

### hideCurrency

Render only the formatted number, without any currency symbol, code, or name. Locale-driven separators and decimal places are preserved.

<Demo data={hideCurrencyDemo} />

### groupDigits

<Demo data={groupDigitsDemo} />

### 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.

<Demo data={largeNumbersDemo} />

Expand Down
22 changes: 18 additions & 4 deletions apps/www/src/content/docs/components/amount/props.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
export interface AmountProps {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AmountProps is duplicated in in amount.tsx. I think we can export it from there and use it here.

/**
* 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
Expand Down Expand Up @@ -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
* <Amount value={1299} hideCurrency /> => "12.99"
*/
hideCurrency?: boolean;
}
65 changes: 65 additions & 0 deletions packages/raystack/components/amount/__tests__/amount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,5 +203,70 @@ describe('Amount', () => {
expect(element).toBeInTheDocument();
consoleSpy.mockRestore();
});

it('handles negative string values in minor units', () => {
render(<Amount value='-1299' />);
expect(screen.getByText('-$12.99')).toBeInTheDocument();
});
});

describe('BigInt support', () => {
it('formats a bigint as major units regardless of valueInMinorUnits', () => {
render(<Amount value={1299n} />);
expect(screen.getByText('$1,299.00')).toBeInTheDocument();
});

it('matches the major-units result when valueInMinorUnits is false', () => {
render(<Amount value={1299n} valueInMinorUnits={false} />);
expect(screen.getByText('$1,299.00')).toBeInTheDocument();
});

it('preserves precision beyond Number.MAX_SAFE_INTEGER', () => {
render(<Amount value={9999999999999999999n} valueInMinorUnits={false} />);
expect(
screen.getByText('$9,999,999,999,999,999,999.00')
).toBeInTheDocument();
});

it('formats negative bigint values', () => {
render(<Amount value={-1299n} />);
expect(screen.getByText('-$1,299.00')).toBeInTheDocument();
});

it('formats bigint with a zero-decimal currency', () => {
render(<Amount value={1299n} currency='JPY' locale='en-US' />);
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(<Amount value={9999999999999999999n} valueInMinorUnits={false} />);
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});

describe('hideCurrency', () => {
it('hides the currency symbol while preserving formatting', () => {
render(<Amount value={1299} hideCurrency />);
expect(screen.getByText('12.99')).toBeInTheDocument();
});

it('hides currency for bigint values', () => {
render(<Amount value={1299n} hideCurrency />);
expect(screen.getByText('1,299')).toBeInTheDocument();
});

it('overrides currencyDisplay when set', () => {
render(<Amount value={1299} hideCurrency currencyDisplay='code' />);
expect(screen.getByText('12.99')).toBeInTheDocument();
});

it('respects the currency for decimal-place math even when hidden', () => {
render(<Amount value={1299} currency='JPY' hideCurrency />);
expect(screen.getByText('1,299')).toBeInTheDocument();
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
97 changes: 69 additions & 28 deletions packages/raystack/components/amount/amount.tsx
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

 <Amount value='12.99' valueInMinorUnits /> // NaN
 <Amount value={12.99} valueInMinorUnits /> //Working

@Shreyag02 can you check this?

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
* <Amount value={1299} hideCurrency /> => "12.99"
*/
hideCurrency?: boolean;
}

/**
Expand Down Expand Up @@ -151,6 +165,7 @@ export const Amount = ({
maximumFractionDigits,
groupDigits = true,
valueInMinorUnits = true,
hideCurrency = false,
...props
}: AmountProps) => {
try {
Expand All @@ -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.'
);
}

Expand All @@ -171,37 +186,63 @@ 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,
currency: validCurrency.toUpperCase(),
currencyDisplay,
// 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.
let finalBaseValue: number | string | bigint;
if (!hideDecimals) {
finalBaseValue = baseValue;
} else if (typeof baseValue === 'bigint') {
finalBaseValue = baseValue;
} else if (typeof baseValue === 'string') {
finalBaseValue = baseValue.split('.')[0];
} else {
finalBaseValue = Math.trunc(baseValue);
}

const formatOptions: Intl.NumberFormatOptions = {
minimumFractionDigits: hideDecimals ? 0 : minimumFractionDigits,
maximumFractionDigits: hideDecimals ? 0 : maximumFractionDigits,
useGrouping: groupDigits
useGrouping: groupDigits,
...(hideCurrency
? { style: 'decimal' }
: {
style: 'currency',
currency: validCurrency.toUpperCase(),
currencyDisplay
})
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const formattedValue = new Intl.NumberFormat(
locale,
formatOptions
// @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);
).format(finalBaseValue);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return <span {...props}>{formattedValue}</span>;
} catch (error) {
console.error('Error formatting amount:', error);
return <span {...props}>{value}</span>;
return <span {...props}>{String(value)}</span>;
}
};

Expand Down
Loading