Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
62 changes: 58 additions & 4 deletions apps/www/src/content/docs/components/amount/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,6 +60,10 @@ export const playground = {
hideCurrency: {
type: 'checkbox',
defaultValue: false
},
tabularNums: {
type: 'checkbox',
defaultValue: true
}
},
getCode
Expand Down Expand Up @@ -117,19 +131,59 @@ export const currencyDisplayDemo = {
code: `
<Flex gap={4}>
<Amount value={1299} currencyDisplay="symbol" /> {/* $12.99 */}
<Amount value={1299} locale="en-CA" currencyDisplay="symbol" /> {/* US$12.99 */}
<Amount value={1299} locale="en-CA" currencyDisplay="narrowSymbol" /> {/* $12.99 */}
<Amount value={1299} currencyDisplay="code" /> {/* USD 12.99 */}
<Amount value={1299} currencyDisplay="name" /> {/* 12.99 US dollars */}
</Flex>
`
};

export const notationDemo = {
type: 'code',
code: `
<Flex gap={4}>
<Amount value={120000000} notation="compact" /> {/* $1.2M */}
<Amount value={1300000} notation="compact" /> {/* $13K */}
<Amount value={120000000} /> {/* $1,200,000.00 */}
</Flex>
`
};

export const signDisplayDemo = {
type: 'code',
code: `
<Flex gap={4}>
<Amount value={1299} signDisplay="always" /> {/* +$12.99 */}
<Amount value={-1299} signDisplay="always" /> {/* -$12.99 */}
<Amount value={0} signDisplay="exceptZero" /> {/* $0.00 */}
<Amount value={-1299} signDisplay="never" /> {/* $12.99 */}
</Flex>
`
};

export const tabularNumsDemo = {
type: 'code',
code: `
<Flex direction="column" gap={2}>
{/* Tabular figures (default) keep digits aligned across rows */}
<Amount value={111111} />
<Amount value={909090} />
{/* Proportional figures read better in running text */}
<Text>
You saved <Amount value={1299} tabularNums={false} /> today
</Text>
</Flex>
`
};

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 */}
<Amount value={1299} hideCurrency currencyDisplay="code" />{/* 12.99 (currencyDisplay is ignored) */}
</Flex>
`
};
Expand Down Expand Up @@ -174,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
*/}
<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 */}
<Amount value={99999999999999999} />{/* Exceeds Number.MAX_SAFE_INTEGER (~9 × 10^15), so it logs a console warning */}
</Flex>
`
};
25 changes: 23 additions & 2 deletions apps/www/src/content/docs/components/amount/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
localeDemo,
hideDecimalsDemo,
currencyDisplayDemo,
notationDemo,
signDisplayDemo,
tabularNumsDemo,
hideCurrencyDemo,
groupDigitsDemo,
withTextDemo,
Expand Down Expand Up @@ -48,10 +51,28 @@ 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`).

<Demo data={currencyDisplayDemo} />

### 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. With `hideDecimals`, it rounds to a whole number, so `$1.55M` shows as `$2M`.

<Demo data={notationDemo} />

### Sign display

`signDisplay` sets when the `+` or `-` sign shows. Use `always` to show gains and losses as `+$12.99` and `-$12.99`.

<Demo data={signDisplayDemo} />

### Tabular numbers

Amount uses tabular (fixed-width) figures by default, so digits align across table rows. Set `tabularNums={false}` in running text to use proportional figures.

<Demo data={tabularNumsDemo} />

### Number without currency

Render only the formatted number, without any currency symbol, code, or name. Locale-driven separators and decimal places are preserved.
Expand All @@ -72,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.

<Demo data={hideDecimalsDemo} />

Expand Down
158 changes: 158 additions & 0 deletions packages/raystack/components/amount/__tests__/amount.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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(<Amount value={1299} currency={null as unknown as string} />);
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(<Amount value={1299} currency='eur' locale='en-US' />);
expect(screen.getByText('€12.99')).toBeInTheDocument();
Expand Down Expand Up @@ -132,6 +146,21 @@ describe('Amount', () => {
expect(screen.getByText('$12')).toBeInTheDocument();
});

it('drops the sign when hideDecimals truncates a negative value to zero', () => {
render(<Amount value={-50} hideDecimals />);
expect(screen.getByText('$0')).toBeInTheDocument();
});

it('drops the sign when hideDecimals truncates a negative string to zero', () => {
render(<Amount value='-50' hideDecimals />);
expect(screen.getByText('$0')).toBeInTheDocument();
});

it('keeps the sign when hideDecimals truncates a value below -1', () => {
render(<Amount value={-1299} hideDecimals />);
expect(screen.getByText('-$12')).toBeInTheDocument();
});

it('displays currency as symbol by default', () => {
render(<Amount value={1299} currencyDisplay='symbol' />);
expect(screen.getByText('$12.99')).toBeInTheDocument();
Expand Down Expand Up @@ -224,6 +253,21 @@ describe('Amount', () => {
render(<Amount value='-1299' />);
expect(screen.getByText('-$12.99')).toBeInTheDocument();
});

it('pads string values shorter than the currency decimals', () => {
render(<Amount value='5' />);
expect(screen.getByText('$0.05')).toBeInTheDocument();
});

it('pads negative string values shorter than the currency decimals', () => {
render(<Amount value='-5' />);
expect(screen.getByText('-$0.05')).toBeInTheDocument();
});

it('pads short string values for a 3-decimal currency', () => {
render(<Amount value='5' currency='BHD' hideCurrency />);
expect(screen.getByText('0.005')).toBeInTheDocument();
});
});

describe('BigInt support', () => {
Expand Down Expand Up @@ -321,4 +365,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(
<Amount
value={1299}
currency='USD'
locale='en-CA'
currencyDisplay='narrowSymbol'
/>
);
expect(screen.getByText('$12.99')).toBeInTheDocument();
});

it('matches symbol output for the home locale', () => {
render(<Amount value={1299} currencyDisplay='narrowSymbol' />);
expect(screen.getByText('$12.99')).toBeInTheDocument();
});
});

describe('signDisplay', () => {
it('always shows the sign when signDisplay is always', () => {
render(<Amount value={1299} signDisplay='always' />);
expect(screen.getByText('+$12.99')).toBeInTheDocument();
});

it('shows the sign except for zero when signDisplay is exceptZero', () => {
const { rerender } = render(
<Amount value={1299} signDisplay='exceptZero' />
);
expect(screen.getByText('+$12.99')).toBeInTheDocument();
rerender(<Amount value={0} signDisplay='exceptZero' />);
expect(screen.getByText('$0.00')).toBeInTheDocument();
});

it('hides the sign for negative values when signDisplay is never', () => {
render(<Amount value={-1299} signDisplay='never' />);
expect(screen.getByText('$12.99')).toBeInTheDocument();
});

it('keeps the sign when hideCurrency strips the currency token', () => {
render(<Amount value={1299} signDisplay='always' hideCurrency />);
expect(screen.getByText('+12.99')).toBeInTheDocument();
});
});

describe('notation', () => {
it('renders compact notation for large values', () => {
render(<Amount value={120000000} notation='compact' />);
expect(screen.getByText('$1.2M')).toBeInTheDocument();
});

it('renders standard notation by default', () => {
render(<Amount value={120000000} />);
expect(screen.getByText('$1,200,000.00')).toBeInTheDocument();
});

it('rounds small values to compact defaults (no abbreviation below 1K)', () => {
// Compact notation keeps 2 significant digits by default.
render(<Amount value={1299} notation='compact' />);
expect(screen.getByText('$13')).toBeInTheDocument();
});

it('rounds the abbreviated value with hideDecimals', () => {
render(<Amount value={155000000} notation='compact' hideDecimals />);
expect(screen.getByText('$2M')).toBeInTheDocument();
});

it('works with string values', () => {
render(<Amount value='120000000' notation='compact' />);
expect(screen.getByText('$1.2M')).toBeInTheDocument();
});

it('works with hideCurrency', () => {
render(<Amount value={120000000} notation='compact' hideCurrency />);
expect(screen.getByText('1.2M')).toBeInTheDocument();
});

it('respects explicit fraction digits', () => {
render(
<Amount
value={123400000}
notation='compact'
minimumFractionDigits={2}
maximumFractionDigits={2}
/>
);
expect(screen.getByText('$1.23M')).toBeInTheDocument();
});
});

describe('tabularNums', () => {
it('applies tabular figures by default', () => {
render(<Amount value={1299} />);
const amount = screen.getByText('$12.99');
expect(amount).toHaveClass(styles.tabular);
expect(amount).not.toHaveClass(styles.proportional);
});

it('applies proportional figures when tabularNums is false', () => {
render(<Amount value={1299} tabularNums={false} />);
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', () => {
render(<Amount value={1299} className='custom-class' />);
const amount = screen.getByText('$12.99');
expect(amount).toHaveClass('custom-class');
expect(amount).toHaveClass(styles.tabular);
});
});
});
6 changes: 5 additions & 1 deletion packages/raystack/components/amount/amount.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.amount {
.tabular {
font-variant-numeric: tabular-nums;
}

.proportional {
font-variant-numeric: proportional-nums;
}
Loading
Loading