@umituz/web-design-system 4.29.3 → 4.30.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": "4.29.3",
3
+ "version": "4.30.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.",
@@ -1811,3 +1811,45 @@ describe('FormField auto id (4.29.1)', () => {
1811
1811
  expect((control.getAttribute('aria-describedby') ?? '').length).toBeGreaterThan(0);
1812
1812
  });
1813
1813
  });
1814
+
1815
+ import {
1816
+ Table,
1817
+ TableBody,
1818
+ TableCell,
1819
+ TableHead,
1820
+ TableHeader,
1821
+ TableRow,
1822
+ } from '../presentation/organisms/Table';
1823
+
1824
+ describe('Table compound colSpan (4.29.4)', () => {
1825
+ it('renders a grouped header row with colSpan and scope on th', () => {
1826
+ render(
1827
+ <Table>
1828
+ <TableHeader>
1829
+ <TableRow>
1830
+ <TableHead />
1831
+ <TableHead colSpan={2} scope="colgroup">
1832
+ Orders
1833
+ </TableHead>
1834
+ </TableRow>
1835
+ <TableRow>
1836
+ <TableHead />
1837
+ <TableHead>View</TableHead>
1838
+ <TableHead>Edit</TableHead>
1839
+ </TableRow>
1840
+ </TableHeader>
1841
+ <TableBody>
1842
+ <TableRow>
1843
+ <TableCell>Editor</TableCell>
1844
+ <TableCell>yes</TableCell>
1845
+ <TableCell>no</TableCell>
1846
+ </TableRow>
1847
+ </TableBody>
1848
+ </Table>,
1849
+ );
1850
+ const group = screen.getByText('Orders');
1851
+ expect(group.getAttribute('colspan')).toBe('2');
1852
+ expect(group.getAttribute('scope')).toBe('colgroup');
1853
+ expect(screen.getByText('Editor')).toBeTruthy();
1854
+ });
1855
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Money utils + Money atom — minor-unit contract (ISO 4217 digits, parsing, totals).
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+ import { render, screen } from '@testing-library/react';
7
+ import { cleanup } from '@testing-library/react';
8
+ import { afterEach } from 'vitest';
9
+ import {
10
+ currencyFractionDigits,
11
+ formatMoney,
12
+ formatMoneyCompact,
13
+ toMinor,
14
+ parseAmountToMinor,
15
+ pctOfMinor,
16
+ lineTotals,
17
+ sumTotals,
18
+ sumMinor,
19
+ } from '../infrastructure/utils';
20
+ import { Money } from '../presentation/atoms/Money';
21
+
22
+ afterEach(cleanup);
23
+
24
+ describe('currencyFractionDigits', () => {
25
+ it('defaults to 2 and matches ISO 4217 exceptions case-insensitively', () => {
26
+ expect(currencyFractionDigits('USD')).toBe(2);
27
+ expect(currencyFractionDigits('jpy')).toBe(0);
28
+ expect(currencyFractionDigits('KWD')).toBe(3);
29
+ });
30
+ });
31
+
32
+ describe('formatMoney', () => {
33
+ it('formats minor units with the currency fraction digits', () => {
34
+ expect(formatMoney(1250, 'USD')).toBe('$12.50');
35
+ expect(formatMoney(1250, 'JPY')).toBe('¥1,250');
36
+ expect(formatMoney(1250, 'KWD')).toContain('1.250');
37
+ });
38
+
39
+ it('honors the locale argument', () => {
40
+ expect(formatMoney(1250, 'EUR', 'de-DE')).toContain('12,50 €');
41
+ });
42
+ });
43
+
44
+ describe('formatMoneyCompact', () => {
45
+ it('compacts large amounts to one fraction digit', () => {
46
+ expect(formatMoneyCompact(1_250_000, 'USD')).toBe('$12.5K');
47
+ });
48
+ });
49
+
50
+ describe('toMinor', () => {
51
+ it('converts a major amount with rounding and currency digits', () => {
52
+ expect(toMinor(12.5, 'USD')).toBe(1250);
53
+ expect(toMinor(12.5, 'JPY')).toBe(13);
54
+ });
55
+ });
56
+
57
+ describe('parseAmountToMinor', () => {
58
+ it('parses valid input including comma decimals', () => {
59
+ expect(parseAmountToMinor('12.5', 'USD')).toBe(1250);
60
+ expect(parseAmountToMinor('12,50', 'EUR')).toBe(1250);
61
+ expect(parseAmountToMinor('12', 'JPY')).toBe(12);
62
+ });
63
+
64
+ it('returns null for invalid or over-precise input', () => {
65
+ expect(parseAmountToMinor('12.500', 'USD')).toBeNull();
66
+ expect(parseAmountToMinor('12.5', 'JPY')).toBeNull();
67
+ expect(parseAmountToMinor('-5', 'USD')).toBeNull();
68
+ expect(parseAmountToMinor('abc', 'USD')).toBeNull();
69
+ expect(parseAmountToMinor('', 'USD')).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe('totals math', () => {
74
+ it('computes line totals with half-up tax rounding', () => {
75
+ expect(lineTotals(3, 499, 10)).toEqual({ subtotalMinor: 1497, taxMinor: 150, totalMinor: 1647 });
76
+ expect(pctOfMinor(1495, 10)).toBe(150);
77
+ });
78
+
79
+ it('sums lines into invoice totals', () => {
80
+ const totals = sumTotals([
81
+ { qty: 2, unitPriceMinor: 1000, taxRatePct: 10 },
82
+ { qty: 1, unitPriceMinor: 500, taxRatePct: 0 },
83
+ ]);
84
+ expect(totals).toEqual({ subtotalMinor: 2500, taxMinor: 200, totalMinor: 2700 });
85
+ expect(sumMinor([2500, 200])).toBe(2700);
86
+ });
87
+ });
88
+
89
+ describe('Money atom', () => {
90
+ it('renders the formatted amount', () => {
91
+ render(<Money amountMinor={1250} currency="USD" />);
92
+ expect(screen.getByText('$12.50')).toBeTruthy();
93
+ });
94
+
95
+ it('keeps the tabular-nums class alongside custom classes', () => {
96
+ render(<Money amountMinor={9} currency="JPY" className="text-lg" />);
97
+ const el = screen.getByText('¥9');
98
+ expect(el.className).toContain('tabular-nums');
99
+ expect(el.className).toContain('text-lg');
100
+ });
101
+ });
@@ -32,3 +32,16 @@ export {
32
32
  export type { PageBounds } from './calculation.utils';
33
33
 
34
34
  export { hashSeed } from './hash.util';
35
+
36
+ export {
37
+ currencyFractionDigits,
38
+ formatMoney,
39
+ formatMoneyCompact,
40
+ toMinor,
41
+ parseAmountToMinor,
42
+ pctOfMinor,
43
+ lineTotals,
44
+ sumTotals,
45
+ sumMinor,
46
+ } from './money.utils';
47
+ export type { LineTotals, TotalsInputLine } from './money.utils';
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Money Utils
3
+ * @description Minor-unit money math and locale-aware formatting (ISO 4217)
4
+ */
5
+
6
+ /** Currencies that don't use 2 decimal places. Everything else defaults to 2. */
7
+ const FRACTION_DIGITS: Record<string, number> = {
8
+ BHD: 3,
9
+ IQD: 3,
10
+ JOD: 3,
11
+ KWD: 3,
12
+ LYD: 3,
13
+ OMR: 3,
14
+ TND: 3,
15
+ BIF: 0,
16
+ CLP: 0,
17
+ DJF: 0,
18
+ GNF: 0,
19
+ ISK: 0,
20
+ JPY: 0,
21
+ KMF: 0,
22
+ KRW: 0,
23
+ PYG: 0,
24
+ RWF: 0,
25
+ UGX: 0,
26
+ VND: 0,
27
+ VUV: 0,
28
+ XAF: 0,
29
+ XOF: 0,
30
+ XPF: 0,
31
+ };
32
+
33
+ export function currencyFractionDigits(currency: string): number {
34
+ return FRACTION_DIGITS[currency.toUpperCase()] ?? 2;
35
+ }
36
+
37
+ export function formatMoney(amountMinor: number, currency: string, locale = 'en-US'): string {
38
+ const digits = currencyFractionDigits(currency);
39
+ return new Intl.NumberFormat(locale, {
40
+ style: 'currency',
41
+ currency,
42
+ minimumFractionDigits: digits,
43
+ maximumFractionDigits: digits,
44
+ }).format(amountMinor / 10 ** digits);
45
+ }
46
+
47
+ export function formatMoneyCompact(amountMinor: number, currency: string, locale = 'en-US'): string {
48
+ const digits = currencyFractionDigits(currency);
49
+ return new Intl.NumberFormat(locale, {
50
+ style: 'currency',
51
+ currency,
52
+ notation: 'compact',
53
+ maximumFractionDigits: 1,
54
+ }).format(amountMinor / 10 ** digits);
55
+ }
56
+
57
+ export function toMinor(amount: number, currency: string): number {
58
+ return Math.round(amount * 10 ** currencyFractionDigits(currency));
59
+ }
60
+
61
+ /** Parses user input like '12.50' into minor units for the currency. Null when invalid. */
62
+ export function parseAmountToMinor(input: string, currency: string): number | null {
63
+ const digits = currencyFractionDigits(currency);
64
+ const trimmed = input.trim().replace(',', '.');
65
+ const decimals = digits === 0 ? '' : `(\\.\\d{1,${digits}})?`;
66
+ const pattern = new RegExp(`^\\d+${decimals}$`);
67
+ if (!pattern.test(trimmed)) return null;
68
+ const minor = Math.round(Number.parseFloat(trimmed) * 10 ** digits);
69
+ return Number.isSafeInteger(minor) ? minor : null;
70
+ }
71
+
72
+ /** Percentage of an amount, rounded half-up to whole minor units. */
73
+ export function pctOfMinor(amountMinor: number, pct: number): number {
74
+ return Math.round((amountMinor * pct) / 100);
75
+ }
76
+
77
+ export interface LineTotals {
78
+ subtotalMinor: number;
79
+ taxMinor: number;
80
+ totalMinor: number;
81
+ }
82
+
83
+ export function lineTotals(qty: number, unitPriceMinor: number, taxRatePct: number): LineTotals {
84
+ const subtotalMinor = Math.round(qty * unitPriceMinor);
85
+ const taxMinor = pctOfMinor(subtotalMinor, taxRatePct);
86
+ return { subtotalMinor, taxMinor, totalMinor: subtotalMinor + taxMinor };
87
+ }
88
+
89
+ export interface TotalsInputLine {
90
+ qty: number;
91
+ unitPriceMinor: number;
92
+ taxRatePct: number;
93
+ }
94
+
95
+ export function sumTotals(lines: readonly TotalsInputLine[]): LineTotals {
96
+ let subtotalMinor = 0;
97
+ let taxMinor = 0;
98
+ for (const line of lines) {
99
+ const totals = lineTotals(line.qty, line.unitPriceMinor, line.taxRatePct);
100
+ subtotalMinor += totals.subtotalMinor;
101
+ taxMinor += totals.taxMinor;
102
+ }
103
+ return { subtotalMinor, taxMinor, totalMinor: subtotalMinor + taxMinor };
104
+ }
105
+
106
+ export function sumMinor(amounts: readonly number[]): number {
107
+ return amounts.reduce((acc, value) => acc + value, 0);
108
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Money Component (Atom)
3
+ * @description Locale-aware currency amount from minor units, tabular digits
4
+ */
5
+
6
+ import { forwardRef, type HTMLAttributes } from 'react';
7
+ import { formatMoney } from '../../infrastructure/utils';
8
+ import { cn } from '../../infrastructure/utils';
9
+ import type { BaseProps } from '../../domain/types';
10
+
11
+ export interface MoneyProps extends HTMLAttributes<HTMLSpanElement>, BaseProps {
12
+ /** Integer amount in minor units (e.g. cents) — never floating point. */
13
+ amountMinor: number;
14
+ /** ISO 4217 currency code; drives the fraction digits. */
15
+ currency: string;
16
+ locale?: string;
17
+ }
18
+
19
+ export const Money = forwardRef<HTMLSpanElement, MoneyProps>(
20
+ ({ amountMinor, currency, locale = 'en-US', className, ...props }, ref) => (
21
+ <span ref={ref} className={cn('tabular-nums', className)} {...props}>
22
+ {formatMoney(amountMinor, currency, locale)}
23
+ </span>
24
+ )
25
+ );
26
+
27
+ Money.displayName = 'Money';
@@ -88,3 +88,6 @@ export type { CodeChipProps } from './CodeChip';
88
88
 
89
89
  export { Kbd } from './Kbd';
90
90
  export type { KbdProps } from './Kbd';
91
+
92
+ export { Money } from './Money';
93
+ export type { MoneyProps } from './Money';
@@ -3,7 +3,7 @@
3
3
  * @description Data table component
4
4
  */
5
5
 
6
- import { forwardRef, type HTMLAttributes, type TdHTMLAttributes } from 'react';
6
+ import { forwardRef, type HTMLAttributes, type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
7
7
  import React from 'react';
8
8
  import { cn } from '../../infrastructure/utils';
9
9
  import type { BaseProps } from '../../domain/types';
@@ -78,7 +78,7 @@ export const TableRow = React.memo(forwardRef<HTMLTableRowElement, HTMLAttribute
78
78
 
79
79
  TableRow.displayName = 'TableRow';
80
80
 
81
- export const TableHead = React.memo(forwardRef<HTMLTableCellElement, HTMLAttributes<HTMLTableCellElement>>(
81
+ export const TableHead = React.memo(forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLTableCellElement>>(
82
82
  ({ className, ...props }, ref) => (
83
83
  <th
84
84
  ref={ref}