Skip to content
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")} />{/* $9,999,999,999,999,999,999.00 */}

{/*
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
70 changes: 3 additions & 67 deletions apps/www/src/content/docs/components/amount/props.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,4 @@
export interface AmountProps {
/**
* The monetary value to display
* For large numbers (> 2^53), pass the value as string to maintain precision
* @default 0
* @example
* valueInMinorUnits=true: 1299 => "$12.99"
* valueInMinorUnits=false: 12.99 => "$12.99"
* Large numbers: "999999999999999" => "$9,999,999,999,999.99"
*/
value: number | string;
import type { AmountProps as ApsaraAmountProps } from '@raystack/apsara';
import type { ComponentProps } from 'react';

/**
* ISO 4217 currency code
* @default 'USD'
*/
currency?: string;

/**
* Whether the value is in minor units (cents, paise, etc.)
* If true, the value will be converted based on the currency's decimal places
* If false, the value will be used as is
* @default true
* @example
* USD: 1299 => $12.99 (2 decimals)
* JPY: 1299 => ¥1,299 (0 decimals)
* BHD: 1299 => BHD 1.299 (3 decimals)
*/
valueInMinorUnits?: boolean;

/**
* BCP 47 language tag
* @default 'en-US'
* @example 'en-US', 'de-DE', 'ja-JP'
*/
locale?: string;

/**
* Truncates decimal places
* @default false
*/
hideDecimals?: boolean;

/**
* Currency display format
* @default 'symbol'
* @example 'symbol' - $12.99, 'code' - USD 12.99, 'name' - 12.99 US Dollars
*/
currencyDisplay?: 'symbol' | 'code' | 'name';

/**
* Number of minimum fraction digits
* @default undefined (uses currency's default)
*/
minimumFractionDigits?: number;

/**
* Number of maximum fraction digits
* @default undefined (uses currency's default)
*/
maximumFractionDigits?: number;

/**
* Group digits (e.g., thousand separators)
* @default true
*/
groupDigits?: boolean;
}
export type AmountProps = Omit<ApsaraAmountProps, keyof ComponentProps<'span'>>;
119 changes: 118 additions & 1 deletion packages/raystack/components/amount/__tests__/amount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,27 @@ describe('Amount', () => {
expect(screen.getByText('$1,299.00')).toBeInTheDocument();
});

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

it('handles decimal string values in major units', () => {
render(<Amount value='12.99' valueInMinorUnits={false} />);
expect(screen.getByText('$12.99')).toBeInTheDocument();
});

it('handles decimal strings in minor units the same way as the equivalent number', () => {
// Regression: '12.99' previously coerced to NaN under valueInMinorUnits.
// It must now match the number path: 12.99 / 100 → 0.1299 → "$0.13".
const { rerender } = render(
<Amount value={12.99} valueInMinorUnits={true} />
);
const numberOutput = screen.getByText('$0.13').textContent;
rerender(<Amount value='12.99' valueInMinorUnits={true} />);
expect(screen.getByText('$0.13').textContent).toBe(numberOutput);
});

it('warns when number exceeds safe integer limit', () => {
const consoleSpy = vi
.spyOn(console, 'warn')
Expand Down Expand Up @@ -203,5 +219,106 @@ 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 (valueInMinorUnits is ignored)', () => {
render(<Amount value={1299n} valueInMinorUnits />);
expect(screen.getByText('$1,299.00')).toBeInTheDocument();
});

it('preserves precision beyond Number.MAX_SAFE_INTEGER', () => {
render(<Amount value={9999999999999999999n} />);
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} />);
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('preserves trailing zeros for round amounts (USD)', () => {
render(<Amount value={1200} hideCurrency />);
expect(screen.getByText('12.00')).toBeInTheDocument();
});

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

it('preserves currency fraction digits for a 3-decimal currency (BHD)', () => {
render(<Amount value={1234} currency='BHD' hideCurrency />);
expect(screen.getByText('1.234')).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();
});

it('honors explicit minimumFractionDigits over the currency default', () => {
render(
<Amount
value={1200}
hideCurrency
minimumFractionDigits={4}
maximumFractionDigits={4}
/>
);
expect(screen.getByText('12.0000')).toBeInTheDocument();
});

it('hideDecimals wins over the currency-default fraction digits', () => {
render(<Amount value={1200} hideCurrency hideDecimals />);
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(<Amount value={1299} hideCurrency maximumFractionDigits={1} />);
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(<Amount value={1299} hideCurrency minimumFractionDigits={3} />);
expect(screen.getByText('12.990')).toBeInTheDocument();
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Loading
Loading