@umituz/web-design-system 6.7.0 → 6.9.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/web-design-system",
3
- "version": "6.7.0",
3
+ "version": "6.9.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
+
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,88 @@
1
+
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ formatCurrency,
5
+ formatCurrencyCompact,
6
+ CURRENCY_LOCALES,
7
+ } from '../infrastructure/utils';
8
+
9
+ describe('formatCurrency', () => {
10
+ it('formats major-unit amounts with currency fraction digits', () => {
11
+ expect(formatCurrency(1234.5, 'USD')).toBe('$1,234.50');
12
+ expect(formatCurrency(1000, 'JPY')).toBe('¥1,000');
13
+ expect(formatCurrency(1.234, 'KWD')).toContain('1.234');
14
+ });
15
+
16
+ it('maps currencies to their canonical locale', () => {
17
+ expect(formatCurrency(1234.5, 'TRY')).toContain('1.234,50');
18
+ expect(formatCurrency(1234.5, 'EUR')).toContain('1.234,50');
19
+ expect(formatCurrency(1234.5, 'GBP')).toBe('£1,234.50');
20
+ });
21
+
22
+ it('honors an explicit locale over the currency map', () => {
23
+ expect(formatCurrency(1234.5, 'USD', { locale: 'de-DE' })).toContain('1.234,50');
24
+ });
25
+
26
+ it('drops cents on demand', () => {
27
+ expect(formatCurrency(1234.56, 'USD', { cents: false })).toBe('$1,235');
28
+ });
29
+
30
+ it('applies explicit decimals', () => {
31
+ expect(formatCurrency(1234.5, 'USD', { decimals: 0 })).toBe('$1,235');
32
+ expect(formatCurrency(1234.5, 'USD', { decimals: 2 })).toBe('$1,234.50');
33
+ });
34
+
35
+ it('applies the smart-fraction heuristic', () => {
36
+ expect(formatCurrency(1500, 'USD', { smartFractions: true })).toBe('$1,500');
37
+ expect(formatCurrency(12.34, 'USD', { smartFractions: true })).toBe('$12.34');
38
+ });
39
+
40
+ it('respects currency digits when smart fractions keep cents', () => {
41
+ expect(formatCurrency(12.5, 'JPY', { smartFractions: true })).toBe('¥13');
42
+ });
43
+
44
+ it('compacts large amounts', () => {
45
+ expect(formatCurrency(1_500_000, 'USD', { compact: true })).toBe('$1.5M');
46
+ });
47
+
48
+ it('keeps explicit fraction digits under compact', () => {
49
+ expect(formatCurrency(1_500_000, 'USD', { compact: true, maximumFractionDigits: 2 })).toBe(
50
+ '$1.50M',
51
+ );
52
+ });
53
+
54
+ it('signs and appends the code on demand', () => {
55
+ expect(formatCurrency(12.5, 'USD', { signed: true })).toBe('+$12.50');
56
+ expect(formatCurrency(12.5, 'USD', { showCode: true })).toBe('$12.50 USD');
57
+ });
58
+
59
+ it('passes currencyDisplay through', () => {
60
+ expect(formatCurrency(12.5, 'USD', { currencyDisplay: 'code' })).toBe('USD 12.50');
61
+ });
62
+
63
+ it('formats exact decimal strings without float artifacts', () => {
64
+ expect(formatCurrency('1234.565', 'USD', { maximumFractionDigits: 2 })).toBe('$1,234.57');
65
+ expect(formatCurrency('0.1', 'USD')).toBe('$0.10');
66
+ });
67
+
68
+ it('caches formatters without cross-contaminating options', () => {
69
+ expect(formatCurrency(5, 'USD')).toBe('$5.00');
70
+ expect(formatCurrency(5, 'USD', { cents: false })).toBe('$5');
71
+ expect(formatCurrency(5, 'USD')).toBe('$5.00');
72
+ });
73
+ });
74
+
75
+ describe('formatCurrencyCompact', () => {
76
+ it('compacts with the full option surface', () => {
77
+ expect(formatCurrencyCompact(1_250_000, 'USD')).toBe('$1.3M');
78
+ expect(formatCurrencyCompact(95_000, 'EUR', { maximumFractionDigits: 2 })).toContain('95');
79
+ });
80
+ });
81
+
82
+ describe('CURRENCY_LOCALES', () => {
83
+ it('covers the fleet currency set', () => {
84
+ expect(CURRENCY_LOCALES.USD).toBe('en-US');
85
+ expect(CURRENCY_LOCALES.TRY).toBe('tr-TR');
86
+ expect(CURRENCY_LOCALES.AED).toBe('en-AE');
87
+ });
88
+ });
@@ -1 +1,10 @@
1
- export const formatCount = (value: number, locale: string): string => new Intl.NumberFormat(locale).format(value);
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);
@@ -34,6 +34,7 @@ export { timeAgo } from './time.util';
34
34
  export type { TimeAgoOptions } from './time.util';
35
35
 
36
36
  export { formatCount } from './format.util';
37
+ export type { CountFormatOptions } from './format.util';
37
38
 
38
39
  export { uniqueId } from './id.util';
39
40
 
@@ -47,5 +48,15 @@ export {
47
48
  lineTotals,
48
49
  sumTotals,
49
50
  sumMinor,
51
+ formatCurrency,
52
+ formatCurrencyCompact,
53
+ formatCurrencyMinor,
54
+ CURRENCY_LOCALES,
55
+ } from './money.utils';
56
+ export type {
57
+ LineTotals,
58
+ TotalsInputLine,
59
+ MoneyValue,
60
+ FormatCurrencyOptions,
61
+ MinorCurrencyOptions,
50
62
  } from './money.utils';
51
- export type { LineTotals, TotalsInputLine } from './money.utils';
@@ -99,3 +99,183 @@ export function sumTotals(lines: readonly TotalsInputLine[]): LineTotals {
99
99
  export function sumMinor(amounts: readonly number[]): number {
100
100
  return amounts.reduce((acc, value) => acc + value, 0);
101
101
  }
102
+
103
+ export type MoneyValue = number | string;
104
+
105
+ export const CURRENCY_LOCALES: Readonly<Record<string, string>> = {
106
+ USD: 'en-US',
107
+ EUR: 'de-DE',
108
+ GBP: 'en-GB',
109
+ TRY: 'tr-TR',
110
+ AED: 'en-AE',
111
+ };
112
+
113
+ export interface FormatCurrencyOptions {
114
+ locale?: string;
115
+ compact?: boolean;
116
+ signed?: boolean;
117
+ showCode?: boolean;
118
+ cents?: boolean;
119
+ decimals?: 0 | 2;
120
+ smartFractions?: boolean;
121
+ minimumFractionDigits?: number;
122
+ maximumFractionDigits?: number;
123
+ currencyDisplay?: Intl.NumberFormatOptions['currencyDisplay'] | 'none';
124
+ symbolPosition?: 'auto' | 'prefix' | 'suffix';
125
+ signPosition?: 'auto' | 'afterSymbol';
126
+ compactSuffixCase?: 'auto' | 'si';
127
+ }
128
+
129
+ interface FractionRange {
130
+ min: number;
131
+ max: number;
132
+ }
133
+
134
+ function resolveFractionRange(amount: MoneyValue, currency: string, options: FormatCurrencyOptions): FractionRange {
135
+ const digits = currencyFractionDigits(currency);
136
+ let min = digits;
137
+ let max = digits;
138
+ if (options.smartFractions) {
139
+ const numeric = typeof amount === 'number' ? amount : Number.parseFloat(amount);
140
+ const dropFractions = Math.abs(numeric) >= 1000 || Number.isInteger(numeric);
141
+ min = dropFractions ? 0 : digits;
142
+ max = dropFractions ? 0 : digits;
143
+ }
144
+ if (options.decimals !== undefined) {
145
+ min = options.decimals;
146
+ max = options.decimals;
147
+ }
148
+ if (options.cents === false) {
149
+ min = 0;
150
+ max = 0;
151
+ }
152
+ if (options.minimumFractionDigits !== undefined) min = options.minimumFractionDigits;
153
+ if (options.maximumFractionDigits !== undefined) max = options.maximumFractionDigits;
154
+ if (options.compact && options.maximumFractionDigits === undefined) max = Math.min(max, 1);
155
+ if (min > max) min = max;
156
+ return { min, max };
157
+ }
158
+
159
+ const currencyFormatterCache = new Map<string, Intl.NumberFormat>();
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
+
218
+ export function formatCurrency(
219
+ amount: MoneyValue,
220
+ currency: string,
221
+ options: FormatCurrencyOptions = {},
222
+ ): string {
223
+ const upper = currency.toUpperCase();
224
+ const locale = options.locale ?? CURRENCY_LOCALES[upper] ?? 'en-US';
225
+ const { min, max } = resolveFractionRange(amount, upper, options);
226
+ const key = `${locale}|${upper}|${options.compact ? 'compact' : 'standard'}|${options.signed ? 'signed' : 'auto'}|${options.currencyDisplay ?? 'default'}|${min}|${max}`;
227
+ let formatter = currencyFormatterCache.get(key);
228
+ if (!formatter) {
229
+ formatter = new Intl.NumberFormat(locale, {
230
+ style: 'currency',
231
+ currency: upper,
232
+ notation: options.compact ? 'compact' : 'standard',
233
+ minimumFractionDigits: min,
234
+ maximumFractionDigits: max,
235
+ signDisplay: options.signed ? 'always' : 'auto',
236
+ ...(options.currencyDisplay && options.currencyDisplay !== 'none'
237
+ ? { currencyDisplay: options.currencyDisplay }
238
+ : {}),
239
+ });
240
+ currencyFormatterCache.set(key, formatter);
241
+ }
242
+ const value =
243
+ typeof amount === 'number'
244
+ ? amount
245
+ : (amount as unknown as Parameters<Intl.NumberFormat['format']>[0]);
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);
254
+ return options.showCode ? `${formatted} ${upper}` : formatted;
255
+ }
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
+
275
+ export function formatCurrencyCompact(
276
+ amount: MoneyValue,
277
+ currency: string,
278
+ options: Omit<FormatCurrencyOptions, 'compact'> = {},
279
+ ): string {
280
+ return formatCurrency(amount, currency, { ...options, compact: true });
281
+ }
@@ -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 deltaSeconds = Math.round((date.getTime() - now.getTime()) / 1000);
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');