@umituz/web-design-system 6.8.0 → 6.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/date-format.test.ts +101 -0
- package/src/__tests__/format-currency-compose.test.ts +101 -0
- package/src/infrastructure/utils/date.util.ts +109 -0
- package/src/infrastructure/utils/format.util.ts +10 -1
- package/src/infrastructure/utils/index.ts +12 -1
- package/src/infrastructure/utils/money.utils.ts +90 -3
- package/src/infrastructure/utils/time.util.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-design-system",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Web Design System - Tailwind v4 authored, precompiled CSS shipped. Atomic Design components (Atoms, Molecules, Organisms, Templates) for React applications.",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatDate, formatDateTime, parseDateInput } from '../infrastructure/utils/date.util';
|
|
3
|
+
|
|
4
|
+
const D = (y: number, m: number, d: number) => new Date(y, m - 1, d);
|
|
5
|
+
|
|
6
|
+
describe('parseDateInput', () => {
|
|
7
|
+
it('parses date-only ISO as local midnight, not UTC', () => {
|
|
8
|
+
expect(parseDateInput('2026-11-10')?.getTime()).toBe(D(2026, 11, 10).getTime());
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('passes Date instances through and rejects invalid ones', () => {
|
|
12
|
+
const date = D(2026, 1, 5);
|
|
13
|
+
expect(parseDateInput(date)).toBe(date);
|
|
14
|
+
expect(parseDateInput(new Date('nope'))).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects empty and whitespace strings', () => {
|
|
18
|
+
expect(parseDateInput('')).toBeNull();
|
|
19
|
+
expect(parseDateInput(' ')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('falls back to the standard parser for full ISO strings', () => {
|
|
23
|
+
expect(parseDateInput('2026-11-10T09:30:00')?.getFullYear()).toBe(2026);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('formatDate presets', () => {
|
|
28
|
+
it('medium renders Mon D, YYYY', () => {
|
|
29
|
+
expect(formatDate('2026-11-10')).toBe('Nov 10, 2026');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('long renders weekday, Month D, YYYY', () => {
|
|
33
|
+
expect(formatDate('2026-11-10', 'long')).toBe('Tuesday, November 10, 2026');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('short renders Mon D without the year', () => {
|
|
37
|
+
expect(formatDate('2026-11-10', 'short')).toBe('Nov 10');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('monthYear renders Mon YYYY', () => {
|
|
41
|
+
expect(formatDate('2026-11-10', 'monthYear')).toBe('Nov 2026');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('honors a non-default locale', () => {
|
|
45
|
+
const out = formatDate('2026-11-10', 'medium', { locale: 'de-DE' });
|
|
46
|
+
expect(out.replace(new RegExp(String.fromCharCode(160), 'g'), ' ')).toBe('10. Nov. 2026');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('formatDate numeric style', () => {
|
|
51
|
+
it('renders dmy with the default dot separator', () => {
|
|
52
|
+
expect(formatDate('2026-11-10', 'numeric')).toBe('10.11.2026');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('renders slash-separated dmy, mdy, and ymd orders', () => {
|
|
56
|
+
expect(formatDate('2026-11-10', 'numeric', { order: 'dmy', separator: '/' })).toBe('10/11/2026');
|
|
57
|
+
expect(formatDate('2026-11-10', 'numeric', { order: 'mdy', separator: '/' })).toBe('11/10/2026');
|
|
58
|
+
expect(formatDate('2026-11-10', 'numeric', { order: 'ymd', separator: '-' })).toBe('2026-11-10');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('pads single-digit day and month to two digits', () => {
|
|
62
|
+
expect(formatDate('2026-01-05', 'numeric')).toBe('05.01.2026');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('formatDate invalid inputs', () => {
|
|
67
|
+
it('renders the em dash for null, undefined, and garbage', () => {
|
|
68
|
+
expect(formatDate(null)).toBe('—');
|
|
69
|
+
expect(formatDate(undefined)).toBe('—');
|
|
70
|
+
expect(formatDate('not-a-date')).toBe('—');
|
|
71
|
+
expect(formatDate('', 'numeric')).toBe('—');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('supports a custom invalidOutput', () => {
|
|
75
|
+
expect(formatDate(null, 'medium', { invalidOutput: 'n/a' })).toBe('n/a');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('formatDateTime', () => {
|
|
80
|
+
it('composes the medium date with a 24h clock', () => {
|
|
81
|
+
expect(formatDateTime('2026-11-10T09:30:00')).toBe('Nov 10, 2026, 09:30');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('supports a custom time separator and locale clock', () => {
|
|
85
|
+
const out = formatDateTime('2026-11-10T14:05:00', { timeSeparator: ' · ', locale: 'en-GB' });
|
|
86
|
+
expect(out).toBe('10 Nov 2026 · 14:05');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('renders the placeholder for invalid input', () => {
|
|
90
|
+
expect(formatDateTime('nope')).toBe('—');
|
|
91
|
+
expect(formatDateTime(null, { invalidOutput: '—' })).toBe('—');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('formatter cache', () => {
|
|
96
|
+
it('does not contaminate across locales for the same instant', () => {
|
|
97
|
+
const en = formatDate('2026-11-10');
|
|
98
|
+
formatDate('2026-11-10', 'medium', { locale: 'de-DE' });
|
|
99
|
+
expect(formatDate('2026-11-10')).toBe(en);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
formatCurrency,
|
|
5
|
+
formatCurrencyMinor,
|
|
6
|
+
formatCount,
|
|
7
|
+
timeAgo,
|
|
8
|
+
} from '../infrastructure/utils';
|
|
9
|
+
|
|
10
|
+
describe('formatCurrency symbol composition', () => {
|
|
11
|
+
it('forces the symbol to the front regardless of locale order', () => {
|
|
12
|
+
expect(formatCurrency(1234.56, 'EUR', { locale: 'de-DE', symbolPosition: 'prefix' })).toBe('€1.234,56');
|
|
13
|
+
expect(formatCurrency(1234.5, 'USD', { locale: 'en-US', symbolPosition: 'prefix' })).toBe('$1,234.50');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('forces the symbol to the end with a single space', () => {
|
|
17
|
+
expect(formatCurrency(1234.5, 'USD', { locale: 'en-US', symbolPosition: 'suffix' })).toBe('1,234.50 $');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('moves the sign between symbol and digits on demand', () => {
|
|
21
|
+
expect(
|
|
22
|
+
formatCurrency(-50.25, 'EUR', {
|
|
23
|
+
locale: 'en-US',
|
|
24
|
+
currencyDisplay: 'narrowSymbol',
|
|
25
|
+
symbolPosition: 'prefix',
|
|
26
|
+
signPosition: 'afterSymbol',
|
|
27
|
+
}),
|
|
28
|
+
).toBe('€-50.25');
|
|
29
|
+
expect(formatCurrency(-5, 'USD', { locale: 'en-US', signPosition: 'afterSymbol' })).toBe('$-5.00');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('drops the symbol entirely for symbol-less composites', () => {
|
|
33
|
+
expect(formatCurrency(1500, 'USD', { cents: false, currencyDisplay: 'none' })).toBe('1,500');
|
|
34
|
+
expect(formatCurrency(1500, 'USD', { cents: false, currencyDisplay: 'none', showCode: true })).toBe(
|
|
35
|
+
'1,500 USD',
|
|
36
|
+
);
|
|
37
|
+
expect(formatCurrency(-42.25, 'USD', { currencyDisplay: 'none' })).toBe('-42.25');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('applies SI suffix casing to compact output', () => {
|
|
41
|
+
expect(
|
|
42
|
+
formatCurrency(1500, 'USD', { compact: true, maximumFractionDigits: 1, compactSuffixCase: 'si' }),
|
|
43
|
+
).toBe('$1.5k');
|
|
44
|
+
expect(
|
|
45
|
+
formatCurrency(12500000, 'USD', {
|
|
46
|
+
compact: true,
|
|
47
|
+
minimumFractionDigits: 2,
|
|
48
|
+
maximumFractionDigits: 2,
|
|
49
|
+
compactSuffixCase: 'si',
|
|
50
|
+
}),
|
|
51
|
+
).toBe('$12.50M');
|
|
52
|
+
expect(
|
|
53
|
+
formatCurrency(1500000, 'GBP', { locale: 'en-GB', compact: true, maximumFractionDigits: 1, compactSuffixCase: 'si' }),
|
|
54
|
+
).toBe('£1.5M');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('composes without contaminating the plain formatter cache', () => {
|
|
58
|
+
expect(formatCurrency(5, 'USD')).toBe('$5.00');
|
|
59
|
+
expect(formatCurrency(5, 'USD', { symbolPosition: 'suffix' })).toBe('5.00 $');
|
|
60
|
+
expect(formatCurrency(5, 'USD')).toBe('$5.00');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('formatCurrencyMinor', () => {
|
|
65
|
+
it('divides minor units by the currency digits', () => {
|
|
66
|
+
expect(formatCurrencyMinor(123456, 'USD')).toBe('$1,234.56');
|
|
67
|
+
expect(formatCurrencyMinor(1500, 'JPY')).toBe('¥1,500');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('honors an explicit minor digit override', () => {
|
|
71
|
+
expect(formatCurrencyMinor(1500, 'JPY', { minorDigits: 2 })).toBe('¥15.00');
|
|
72
|
+
expect(formatCurrencyMinor(1500, 'JPY', { minorDigits: 2, maximumFractionDigits: 0 })).toBe('¥15');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('passes the full option surface through', () => {
|
|
76
|
+
expect(formatCurrencyMinor(12500000, 'USD', { compact: true })).toBe('$125.0K');
|
|
77
|
+
expect(formatCurrencyMinor(12500000, 'USD', { compact: true, maximumFractionDigits: 0 })).toBe('$125K');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('formatCount digit options', () => {
|
|
82
|
+
it('keeps the plain default untouched', () => {
|
|
83
|
+
expect(formatCount(1234.567, 'en-US')).toBe('1,234.567');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('applies explicit fraction digits', () => {
|
|
87
|
+
expect(formatCount(1234.567, 'en-US', { maximumFractionDigits: 2 })).toBe('1,234.57');
|
|
88
|
+
expect(formatCount(1234.5, 'de-DE', { minimumFractionDigits: 2 })).toBe('1.234,50');
|
|
89
|
+
expect(formatCount(1234, 'en-US', { minimumFractionDigits: 0, maximumFractionDigits: 1 })).toBe('1,234');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('timeAgo invalid date handling', () => {
|
|
94
|
+
it('returns the fallback instead of throwing when requested', () => {
|
|
95
|
+
expect(timeAgo('not-a-date', { invalidDateOutput: '—' })).toBe('—');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('still throws on invalid dates by default', () => {
|
|
99
|
+
expect(() => timeAgo('not-a-date')).toThrow();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type DateStyle = 'medium' | 'long' | 'short' | 'monthYear' | 'numeric';
|
|
2
|
+
|
|
3
|
+
export interface DateFormatOptions {
|
|
4
|
+
locale?: string;
|
|
5
|
+
order?: 'dmy' | 'mdy' | 'ymd';
|
|
6
|
+
separator?: string;
|
|
7
|
+
invalidOutput?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DateTimeFormatOptions extends DateFormatOptions {
|
|
11
|
+
timeSeparator?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LOCALE = 'en';
|
|
15
|
+
const DEFAULT_INVALID_OUTPUT = '—';
|
|
16
|
+
const DEFAULT_SEPARATOR = '.';
|
|
17
|
+
|
|
18
|
+
const PRESET_OPTIONS: Record<Exclude<DateStyle, 'numeric'>, Intl.DateTimeFormatOptions> = {
|
|
19
|
+
medium: { day: 'numeric', month: 'short', year: 'numeric' },
|
|
20
|
+
long: { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' },
|
|
21
|
+
short: { day: 'numeric', month: 'short' },
|
|
22
|
+
monthYear: { month: 'short', year: 'numeric' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
|
26
|
+
|
|
27
|
+
const cachedFormatter = (locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat => {
|
|
28
|
+
const key = `${locale}|${JSON.stringify(options)}`;
|
|
29
|
+
let formatter = formatterCache.get(key);
|
|
30
|
+
if (!formatter) {
|
|
31
|
+
formatter = new Intl.DateTimeFormat(locale, options);
|
|
32
|
+
formatterCache.set(key, formatter);
|
|
33
|
+
}
|
|
34
|
+
return formatter;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const parseDateInput = (input: string | Date): Date | null => {
|
|
38
|
+
if (input instanceof Date) {
|
|
39
|
+
return Number.isNaN(input.getTime()) ? null : input;
|
|
40
|
+
}
|
|
41
|
+
if (typeof input !== 'string' || input.trim() === '') {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input.trim());
|
|
45
|
+
if (dateOnly) {
|
|
46
|
+
const local = new Date(
|
|
47
|
+
Number(dateOnly[1]),
|
|
48
|
+
Number(dateOnly[2]) - 1,
|
|
49
|
+
Number(dateOnly[3]),
|
|
50
|
+
);
|
|
51
|
+
return Number.isNaN(local.getTime()) ? null : local;
|
|
52
|
+
}
|
|
53
|
+
const parsed = new Date(input);
|
|
54
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const formatNumeric = (date: Date, locale: string, order: NonNullable<DateFormatOptions['order']>, separator: string): string => {
|
|
58
|
+
const parts = cachedFormatter(locale, {
|
|
59
|
+
day: '2-digit',
|
|
60
|
+
month: '2-digit',
|
|
61
|
+
year: 'numeric',
|
|
62
|
+
}).formatToParts(date);
|
|
63
|
+
const valueOf = (type: string): string => parts.find((part) => part.type === type)?.value ?? '';
|
|
64
|
+
const day = valueOf('day');
|
|
65
|
+
const month = valueOf('month');
|
|
66
|
+
const year = valueOf('year');
|
|
67
|
+
if (order === 'mdy') return `${month}${separator}${day}${separator}${year}`;
|
|
68
|
+
if (order === 'ymd') return `${year}${separator}${month}${separator}${day}`;
|
|
69
|
+
return `${day}${separator}${month}${separator}${year}`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const formatDate = (
|
|
73
|
+
input: string | Date | null | undefined,
|
|
74
|
+
style: DateStyle = 'medium',
|
|
75
|
+
options: DateFormatOptions = {},
|
|
76
|
+
): string => {
|
|
77
|
+
if (input === null || input === undefined) {
|
|
78
|
+
return options.invalidOutput ?? DEFAULT_INVALID_OUTPUT;
|
|
79
|
+
}
|
|
80
|
+
const date = parseDateInput(input);
|
|
81
|
+
if (!date) {
|
|
82
|
+
return options.invalidOutput ?? DEFAULT_INVALID_OUTPUT;
|
|
83
|
+
}
|
|
84
|
+
const locale = options.locale ?? DEFAULT_LOCALE;
|
|
85
|
+
if (style === 'numeric') {
|
|
86
|
+
return formatNumeric(date, locale, options.order ?? 'dmy', options.separator ?? DEFAULT_SEPARATOR);
|
|
87
|
+
}
|
|
88
|
+
return cachedFormatter(locale, PRESET_OPTIONS[style]).format(date);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const formatDateTime = (
|
|
92
|
+
input: string | Date | null | undefined,
|
|
93
|
+
options: DateTimeFormatOptions = {},
|
|
94
|
+
): string => {
|
|
95
|
+
if (input === null || input === undefined) {
|
|
96
|
+
return options.invalidOutput ?? DEFAULT_INVALID_OUTPUT;
|
|
97
|
+
}
|
|
98
|
+
const date = parseDateInput(input);
|
|
99
|
+
if (!date) {
|
|
100
|
+
return options.invalidOutput ?? DEFAULT_INVALID_OUTPUT;
|
|
101
|
+
}
|
|
102
|
+
const locale = options.locale ?? DEFAULT_LOCALE;
|
|
103
|
+
const clock = cachedFormatter(locale, {
|
|
104
|
+
hour: '2-digit',
|
|
105
|
+
minute: '2-digit',
|
|
106
|
+
hour12: false,
|
|
107
|
+
}).format(date);
|
|
108
|
+
return `${formatDate(date, 'medium', { locale })}${options.timeSeparator ?? ', '}${clock}`;
|
|
109
|
+
};
|
|
@@ -1 +1,10 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface CountFormatOptions {
|
|
2
|
+
minimumFractionDigits?: number;
|
|
3
|
+
maximumFractionDigits?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const formatCount = (
|
|
7
|
+
value: number,
|
|
8
|
+
locale: string,
|
|
9
|
+
options: CountFormatOptions = {},
|
|
10
|
+
): string => new Intl.NumberFormat(locale, options).format(value);
|
|
@@ -33,7 +33,11 @@ export { initialsOf, nameHue } from './name.util';
|
|
|
33
33
|
export { timeAgo } from './time.util';
|
|
34
34
|
export type { TimeAgoOptions } from './time.util';
|
|
35
35
|
|
|
36
|
+
export { formatDate, formatDateTime, parseDateInput } from './date.util';
|
|
37
|
+
export type { DateStyle, DateFormatOptions, DateTimeFormatOptions } from './date.util';
|
|
38
|
+
|
|
36
39
|
export { formatCount } from './format.util';
|
|
40
|
+
export type { CountFormatOptions } from './format.util';
|
|
37
41
|
|
|
38
42
|
export { uniqueId } from './id.util';
|
|
39
43
|
|
|
@@ -49,6 +53,13 @@ export {
|
|
|
49
53
|
sumMinor,
|
|
50
54
|
formatCurrency,
|
|
51
55
|
formatCurrencyCompact,
|
|
56
|
+
formatCurrencyMinor,
|
|
52
57
|
CURRENCY_LOCALES,
|
|
53
58
|
} from './money.utils';
|
|
54
|
-
export type {
|
|
59
|
+
export type {
|
|
60
|
+
LineTotals,
|
|
61
|
+
TotalsInputLine,
|
|
62
|
+
MoneyValue,
|
|
63
|
+
FormatCurrencyOptions,
|
|
64
|
+
MinorCurrencyOptions,
|
|
65
|
+
} from './money.utils';
|
|
@@ -120,7 +120,10 @@ export interface FormatCurrencyOptions {
|
|
|
120
120
|
smartFractions?: boolean;
|
|
121
121
|
minimumFractionDigits?: number;
|
|
122
122
|
maximumFractionDigits?: number;
|
|
123
|
-
currencyDisplay?: Intl.NumberFormatOptions['currencyDisplay'];
|
|
123
|
+
currencyDisplay?: Intl.NumberFormatOptions['currencyDisplay'] | 'none';
|
|
124
|
+
symbolPosition?: 'auto' | 'prefix' | 'suffix';
|
|
125
|
+
signPosition?: 'auto' | 'afterSymbol';
|
|
126
|
+
compactSuffixCase?: 'auto' | 'si';
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
interface FractionRange {
|
|
@@ -155,6 +158,63 @@ function resolveFractionRange(amount: MoneyValue, currency: string, options: For
|
|
|
155
158
|
|
|
156
159
|
const currencyFormatterCache = new Map<string, Intl.NumberFormat>();
|
|
157
160
|
|
|
161
|
+
const SIGN_PART_TYPES = new Set(['minusSign', 'plusSign']);
|
|
162
|
+
|
|
163
|
+
function composeCurrencyParts(parts: Intl.NumberFormatPart[], options: FormatCurrencyOptions): string {
|
|
164
|
+
const mapped =
|
|
165
|
+
options.compactSuffixCase === 'si'
|
|
166
|
+
? parts.map((part) =>
|
|
167
|
+
part.type === 'compact'
|
|
168
|
+
? { ...part, value: part.value.toLowerCase() === 'k' ? 'k' : part.value.toUpperCase() }
|
|
169
|
+
: part,
|
|
170
|
+
)
|
|
171
|
+
: parts;
|
|
172
|
+
const dropSymbol = options.currencyDisplay === 'none';
|
|
173
|
+
const position = options.symbolPosition ?? 'auto';
|
|
174
|
+
const reposition = dropSymbol || position === 'prefix' || position === 'suffix';
|
|
175
|
+
if (!reposition && options.signPosition !== 'afterSymbol') {
|
|
176
|
+
return mapped.map((part) => part.value).join('');
|
|
177
|
+
}
|
|
178
|
+
if (!reposition) {
|
|
179
|
+
const signs = mapped.filter((part) => SIGN_PART_TYPES.has(part.type)).map((part) => part.value).join('');
|
|
180
|
+
const chunks: string[] = [];
|
|
181
|
+
let signsEmitted = false;
|
|
182
|
+
for (const part of mapped) {
|
|
183
|
+
if (SIGN_PART_TYPES.has(part.type)) continue;
|
|
184
|
+
chunks.push(part.value);
|
|
185
|
+
if (!signsEmitted && part.type === 'currency') {
|
|
186
|
+
chunks.push(signs);
|
|
187
|
+
signsEmitted = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!signsEmitted) chunks.push(signs);
|
|
191
|
+
return chunks.join('');
|
|
192
|
+
}
|
|
193
|
+
const currencyIndexes: number[] = [];
|
|
194
|
+
mapped.forEach((part, index) => {
|
|
195
|
+
if (part.type === 'currency') currencyIndexes.push(index);
|
|
196
|
+
});
|
|
197
|
+
const removed = new Set<number>(currencyIndexes);
|
|
198
|
+
for (const index of currencyIndexes) {
|
|
199
|
+
const next = mapped[index + 1];
|
|
200
|
+
const prev = mapped[index - 1];
|
|
201
|
+
if (next && next.type === 'literal' && next.value.trim() === '') removed.add(index + 1);
|
|
202
|
+
else if (prev && prev.type === 'literal' && prev.value.trim() === '') removed.add(index - 1);
|
|
203
|
+
}
|
|
204
|
+
const symbol = mapped
|
|
205
|
+
.filter((_, index) => currencyIndexes.includes(index))
|
|
206
|
+
.map((part) => part.value)
|
|
207
|
+
.join('');
|
|
208
|
+
const keep = mapped.filter((_, index) => !removed.has(index));
|
|
209
|
+
if (dropSymbol) return keep.map((part) => part.value).join('');
|
|
210
|
+
if (position === 'prefix') {
|
|
211
|
+
const signs = keep.filter((part) => SIGN_PART_TYPES.has(part.type)).map((part) => part.value).join('');
|
|
212
|
+
const body = keep.filter((part) => !SIGN_PART_TYPES.has(part.type)).map((part) => part.value).join('');
|
|
213
|
+
return options.signPosition === 'afterSymbol' ? `${symbol}${signs}${body}` : `${symbol}${body}`;
|
|
214
|
+
}
|
|
215
|
+
return `${keep.map((part) => part.value).join('')} ${symbol}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
158
218
|
export function formatCurrency(
|
|
159
219
|
amount: MoneyValue,
|
|
160
220
|
currency: string,
|
|
@@ -173,7 +233,9 @@ export function formatCurrency(
|
|
|
173
233
|
minimumFractionDigits: min,
|
|
174
234
|
maximumFractionDigits: max,
|
|
175
235
|
signDisplay: options.signed ? 'always' : 'auto',
|
|
176
|
-
...(options.currencyDisplay
|
|
236
|
+
...(options.currencyDisplay && options.currencyDisplay !== 'none'
|
|
237
|
+
? { currencyDisplay: options.currencyDisplay }
|
|
238
|
+
: {}),
|
|
177
239
|
});
|
|
178
240
|
currencyFormatterCache.set(key, formatter);
|
|
179
241
|
}
|
|
@@ -181,10 +243,35 @@ export function formatCurrency(
|
|
|
181
243
|
typeof amount === 'number'
|
|
182
244
|
? amount
|
|
183
245
|
: (amount as unknown as Parameters<Intl.NumberFormat['format']>[0]);
|
|
184
|
-
const
|
|
246
|
+
const needsComposition =
|
|
247
|
+
options.currencyDisplay === 'none' ||
|
|
248
|
+
(options.symbolPosition !== undefined && options.symbolPosition !== 'auto') ||
|
|
249
|
+
options.signPosition === 'afterSymbol' ||
|
|
250
|
+
options.compactSuffixCase === 'si';
|
|
251
|
+
const formatted = needsComposition
|
|
252
|
+
? composeCurrencyParts(formatter.formatToParts(value), options)
|
|
253
|
+
: formatter.format(value);
|
|
185
254
|
return options.showCode ? `${formatted} ${upper}` : formatted;
|
|
186
255
|
}
|
|
187
256
|
|
|
257
|
+
export interface MinorCurrencyOptions extends FormatCurrencyOptions {
|
|
258
|
+
minorDigits?: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function formatCurrencyMinor(
|
|
262
|
+
amountMinor: number,
|
|
263
|
+
currency: string,
|
|
264
|
+
options: MinorCurrencyOptions = {},
|
|
265
|
+
): string {
|
|
266
|
+
const { minorDigits, ...rest } = options;
|
|
267
|
+
const digits = minorDigits ?? currencyFractionDigits(currency);
|
|
268
|
+
if (minorDigits !== undefined) {
|
|
269
|
+
rest.minimumFractionDigits = rest.minimumFractionDigits ?? minorDigits;
|
|
270
|
+
rest.maximumFractionDigits = rest.maximumFractionDigits ?? minorDigits;
|
|
271
|
+
}
|
|
272
|
+
return formatCurrency(amountMinor / 10 ** digits, currency, rest);
|
|
273
|
+
}
|
|
274
|
+
|
|
188
275
|
export function formatCurrencyCompact(
|
|
189
276
|
amount: MoneyValue,
|
|
190
277
|
currency: string,
|
|
@@ -2,6 +2,7 @@ export interface TimeAgoOptions {
|
|
|
2
2
|
now?: Date;
|
|
3
3
|
locale?: string;
|
|
4
4
|
compact?: boolean;
|
|
5
|
+
invalidDateOutput?: string;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
const MINUTE = 60;
|
|
@@ -14,7 +15,9 @@ const YEAR = 365.25 * DAY;
|
|
|
14
15
|
export const timeAgo = (input: Date | string | number, options: TimeAgoOptions = {}): string => {
|
|
15
16
|
const { now = new Date(), locale = 'en', compact = false } = options;
|
|
16
17
|
const date = input instanceof Date ? input : new Date(input);
|
|
17
|
-
const
|
|
18
|
+
const time = date.getTime();
|
|
19
|
+
if (Number.isNaN(time) && options.invalidDateOutput !== undefined) return options.invalidDateOutput;
|
|
20
|
+
const deltaSeconds = Math.round((time - now.getTime()) / 1000);
|
|
18
21
|
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: compact ? 'narrow' : 'long' });
|
|
19
22
|
const abs = Math.abs(deltaSeconds);
|
|
20
23
|
if (abs < 45) return formatter.format(0, 'second');
|