@umituz/web-design-system 6.9.0 → 6.11.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.9.0",
3
+ "version": "6.11.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,126 @@
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('preset overrides', () => {
96
+ it('drops the weekday from the long style', () => {
97
+ expect(formatDate('2026-11-10', 'long', { weekday: 'none' })).toBe('November 10, 2026');
98
+ });
99
+
100
+ it('shortens the weekday of the long style', () => {
101
+ expect(formatDate('2026-11-10', 'long', { weekday: 'short' })).toBe('Tue, November 10, 2026');
102
+ });
103
+
104
+ it('lengthens the month of the monthYear style', () => {
105
+ expect(formatDate('2026-01-15', 'monthYear', { month: 'long' })).toBe('January 2026');
106
+ });
107
+
108
+ it('lengthens the month of the medium style', () => {
109
+ expect(formatDate('2026-01-15', 'medium', { month: 'long' })).toBe('January 15, 2026');
110
+ });
111
+
112
+ it('does not leak overrides into other calls through the cache', () => {
113
+ expect(formatDate('2026-11-10', 'monthYear', { month: 'long' })).toBe('November 2026');
114
+ expect(formatDate('2026-11-10', 'monthYear')).toBe('Nov 2026');
115
+ expect(formatDate('2026-11-10', 'long', { weekday: 'none' })).toBe('November 10, 2026');
116
+ expect(formatDate('2026-11-10', 'long')).toBe('Tuesday, November 10, 2026');
117
+ });
118
+ });
119
+
120
+ describe('formatter cache', () => {
121
+ it('does not contaminate across locales for the same instant', () => {
122
+ const en = formatDate('2026-11-10');
123
+ formatDate('2026-11-10', 'medium', { locale: 'de-DE' });
124
+ expect(formatDate('2026-11-10')).toBe(en);
125
+ });
126
+ });
@@ -0,0 +1,124 @@
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
+ weekday?: 'short' | 'long' | 'none';
9
+ month?: 'short' | 'long';
10
+ }
11
+
12
+ export interface DateTimeFormatOptions extends DateFormatOptions {
13
+ timeSeparator?: string;
14
+ }
15
+
16
+ const DEFAULT_LOCALE = 'en';
17
+ const DEFAULT_INVALID_OUTPUT = '—';
18
+ const DEFAULT_SEPARATOR = '.';
19
+
20
+ const PRESET_OPTIONS: Record<Exclude<DateStyle, 'numeric'>, Intl.DateTimeFormatOptions> = {
21
+ medium: { day: 'numeric', month: 'short', year: 'numeric' },
22
+ long: { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' },
23
+ short: { day: 'numeric', month: 'short' },
24
+ monthYear: { month: 'short', year: 'numeric' },
25
+ };
26
+
27
+ const formatterCache = new Map<string, Intl.DateTimeFormat>();
28
+
29
+ const cachedFormatter = (locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat => {
30
+ const key = `${locale}|${JSON.stringify(options)}`;
31
+ let formatter = formatterCache.get(key);
32
+ if (!formatter) {
33
+ formatter = new Intl.DateTimeFormat(locale, options);
34
+ formatterCache.set(key, formatter);
35
+ }
36
+ return formatter;
37
+ };
38
+
39
+ const resolveOptions = (style: Exclude<DateStyle, 'numeric'>, options: DateFormatOptions): Intl.DateTimeFormatOptions => {
40
+ const resolved: Intl.DateTimeFormatOptions = { ...PRESET_OPTIONS[style] };
41
+ if (options.weekday === 'none') {
42
+ delete resolved.weekday;
43
+ } else if (options.weekday !== undefined) {
44
+ resolved.weekday = options.weekday;
45
+ }
46
+ if (options.month !== undefined) {
47
+ resolved.month = options.month;
48
+ }
49
+ return resolved;
50
+ };
51
+
52
+ export const parseDateInput = (input: string | Date): Date | null => {
53
+ if (input instanceof Date) {
54
+ return Number.isNaN(input.getTime()) ? null : input;
55
+ }
56
+ if (typeof input !== 'string' || input.trim() === '') {
57
+ return null;
58
+ }
59
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input.trim());
60
+ if (dateOnly) {
61
+ const local = new Date(
62
+ Number(dateOnly[1]),
63
+ Number(dateOnly[2]) - 1,
64
+ Number(dateOnly[3]),
65
+ );
66
+ return Number.isNaN(local.getTime()) ? null : local;
67
+ }
68
+ const parsed = new Date(input);
69
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
70
+ };
71
+
72
+ const formatNumeric = (date: Date, locale: string, order: NonNullable<DateFormatOptions['order']>, separator: string): string => {
73
+ const parts = cachedFormatter(locale, {
74
+ day: '2-digit',
75
+ month: '2-digit',
76
+ year: 'numeric',
77
+ }).formatToParts(date);
78
+ const valueOf = (type: string): string => parts.find((part) => part.type === type)?.value ?? '';
79
+ const day = valueOf('day');
80
+ const month = valueOf('month');
81
+ const year = valueOf('year');
82
+ if (order === 'mdy') return `${month}${separator}${day}${separator}${year}`;
83
+ if (order === 'ymd') return `${year}${separator}${month}${separator}${day}`;
84
+ return `${day}${separator}${month}${separator}${year}`;
85
+ };
86
+
87
+ export const formatDate = (
88
+ input: string | Date | null | undefined,
89
+ style: DateStyle = 'medium',
90
+ options: DateFormatOptions = {},
91
+ ): string => {
92
+ if (input === null || input === undefined) {
93
+ return options.invalidOutput ?? DEFAULT_INVALID_OUTPUT;
94
+ }
95
+ const date = parseDateInput(input);
96
+ if (!date) {
97
+ return options.invalidOutput ?? DEFAULT_INVALID_OUTPUT;
98
+ }
99
+ const locale = options.locale ?? DEFAULT_LOCALE;
100
+ if (style === 'numeric') {
101
+ return formatNumeric(date, locale, options.order ?? 'dmy', options.separator ?? DEFAULT_SEPARATOR);
102
+ }
103
+ return cachedFormatter(locale, resolveOptions(style, options)).format(date);
104
+ };
105
+
106
+ export const formatDateTime = (
107
+ input: string | Date | null | undefined,
108
+ options: DateTimeFormatOptions = {},
109
+ ): string => {
110
+ if (input === null || input === undefined) {
111
+ return options.invalidOutput ?? DEFAULT_INVALID_OUTPUT;
112
+ }
113
+ const date = parseDateInput(input);
114
+ if (!date) {
115
+ return options.invalidOutput ?? DEFAULT_INVALID_OUTPUT;
116
+ }
117
+ const locale = options.locale ?? DEFAULT_LOCALE;
118
+ const clock = cachedFormatter(locale, {
119
+ hour: '2-digit',
120
+ minute: '2-digit',
121
+ hour12: false,
122
+ }).format(date);
123
+ return `${formatDate(date, 'medium', { locale })}${options.timeSeparator ?? ', '}${clock}`;
124
+ };
@@ -33,6 +33,9 @@ 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';
37
40
  export type { CountFormatOptions } from './format.util';
38
41