@umituz/web-design-system 6.2.0 → 6.4.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__/confirm-provider.test.tsx +77 -0
- package/src/__tests__/name.test.ts +45 -0
- package/src/__tests__/time-ago.test.ts +41 -0
- package/src/infrastructure/utils/index.ts +5 -0
- package/src/infrastructure/utils/name.util.ts +12 -0
- package/src/infrastructure/utils/time.util.ts +27 -0
- package/src/presentation/molecules/Avatar.tsx +1 -10
- package/src/presentation/organisms/ConfirmProvider.tsx +56 -0
- package/src/presentation/organisms/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-design-system",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.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,77 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { ConfirmProvider, useConfirm } from '../presentation/organisms/ConfirmProvider';
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
function Probe({ onResult }: { onResult: (value: boolean) => void }) {
|
|
8
|
+
const confirm = useConfirm();
|
|
9
|
+
return (
|
|
10
|
+
<button
|
|
11
|
+
type="button"
|
|
12
|
+
onClick={() => {
|
|
13
|
+
void confirm({ title: 'Delete item?', body: 'This cannot be undone.' }).then(onResult);
|
|
14
|
+
}}
|
|
15
|
+
>
|
|
16
|
+
ask
|
|
17
|
+
</button>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('ConfirmProvider', () => {
|
|
22
|
+
it('resolves true when confirmed and closes the dialog', async () => {
|
|
23
|
+
let result: boolean | undefined;
|
|
24
|
+
render(
|
|
25
|
+
<ConfirmProvider>
|
|
26
|
+
<Probe onResult={(v) => { result = v; }} />
|
|
27
|
+
</ConfirmProvider>,
|
|
28
|
+
);
|
|
29
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
30
|
+
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
|
|
31
|
+
expect(screen.getByRole('dialog')).toBeTruthy();
|
|
32
|
+
expect(screen.getByText('This cannot be undone.')).toBeTruthy();
|
|
33
|
+
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
|
34
|
+
await waitFor(() => expect(result).toBe(true));
|
|
35
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('resolves false when cancelled', async () => {
|
|
39
|
+
let result: boolean | undefined;
|
|
40
|
+
render(
|
|
41
|
+
<ConfirmProvider>
|
|
42
|
+
<Probe onResult={(v) => { result = v; }} />
|
|
43
|
+
</ConfirmProvider>,
|
|
44
|
+
);
|
|
45
|
+
fireEvent.click(screen.getByRole('button', { name: 'ask' }));
|
|
46
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
47
|
+
await waitFor(() => expect(result).toBe(false));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('honors custom labels and tone', () => {
|
|
51
|
+
function LabelledProbe() {
|
|
52
|
+
const confirm = useConfirm();
|
|
53
|
+
return (
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => {
|
|
57
|
+
void confirm({ title: 'Leave page?', tone: 'danger', confirmLabel: 'Leave', cancelLabel: 'Stay' });
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
go
|
|
61
|
+
</button>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
render(
|
|
65
|
+
<ConfirmProvider>
|
|
66
|
+
<LabelledProbe />
|
|
67
|
+
</ConfirmProvider>,
|
|
68
|
+
);
|
|
69
|
+
fireEvent.click(screen.getByRole('button', { name: 'go' }));
|
|
70
|
+
expect(screen.getByRole('button', { name: 'Leave' })).toBeTruthy();
|
|
71
|
+
expect(screen.getByRole('button', { name: 'Stay' })).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('throws when used outside the provider', () => {
|
|
75
|
+
expect(() => render(<Probe onResult={() => undefined} />)).toThrow(/ConfirmProvider/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { initialsOf, nameHue } from '../infrastructure/utils';
|
|
3
|
+
|
|
4
|
+
describe('initialsOf', () => {
|
|
5
|
+
it('takes first and last word initials', () => {
|
|
6
|
+
expect(initialsOf('Kemal Sunal')).toBe('KS');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('uses the first two letters of a single word', () => {
|
|
10
|
+
expect(initialsOf('Suna')).toBe('SU');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('skips middle words', () => {
|
|
14
|
+
expect(initialsOf('Ada Lovelace Byron')).toBe('AB');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('collapses repeated whitespace and trims', () => {
|
|
18
|
+
expect(initialsOf(' Yasar Kemal ')).toBe('YK');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns empty for blank input', () => {
|
|
22
|
+
expect(initialsOf(' ')).toBe('');
|
|
23
|
+
expect(initialsOf('')).toBe('');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('nameHue', () => {
|
|
28
|
+
it('stays within the hue range', () => {
|
|
29
|
+
for (const seed of ['a', 'b', 'buildora', 'x-y-z', 'Zeynep Hoca']) {
|
|
30
|
+
const hue = nameHue(seed);
|
|
31
|
+
expect(hue).toBeGreaterThanOrEqual(0);
|
|
32
|
+
expect(hue).toBeLessThan(360);
|
|
33
|
+
expect(Number.isInteger(hue)).toBe(true);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is deterministic for the same seed', () => {
|
|
38
|
+
expect(nameHue('petvia')).toBe(nameHue('petvia'));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('spreads different seeds across the wheel', () => {
|
|
42
|
+
const hues = new Set(['aa', 'ab', 'ba', 'bb', 'cc'].map(nameHue));
|
|
43
|
+
expect(hues.size).toBeGreaterThan(1);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { timeAgo } from '../infrastructure/utils';
|
|
3
|
+
|
|
4
|
+
const now = new Date('2026-11-09T12:00:00Z');
|
|
5
|
+
const at = (offsetSeconds: number) => new Date(now.getTime() + offsetSeconds * 1000);
|
|
6
|
+
|
|
7
|
+
describe('timeAgo', () => {
|
|
8
|
+
it('returns now inside the 45s window', () => {
|
|
9
|
+
expect(timeAgo(at(-20), { now })).toBe('now');
|
|
10
|
+
expect(timeAgo(at(10), { now })).toBe('now');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('formats past minutes and hours', () => {
|
|
14
|
+
expect(timeAgo(at(-5 * 60), { now })).toBe('5 minutes ago');
|
|
15
|
+
expect(timeAgo(at(-3 * 3600), { now })).toBe('3 hours ago');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('uses auto numerics for single units', () => {
|
|
19
|
+
expect(timeAgo(at(-86400), { now })).toBe('yesterday');
|
|
20
|
+
expect(timeAgo(at(86400), { now })).toBe('tomorrow');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('formats days and weeks', () => {
|
|
24
|
+
expect(timeAgo(at(-4 * 86400), { now })).toBe('4 days ago');
|
|
25
|
+
expect(timeAgo(at(-14 * 86400), { now })).toBe('2 weeks ago');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('caps into months and years', () => {
|
|
29
|
+
expect(timeAgo(at(-70 * 86400), { now })).toBe('2 months ago');
|
|
30
|
+
expect(timeAgo(at(-800 * 86400), { now })).toBe('2 years ago');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('compacts with narrow style', () => {
|
|
34
|
+
expect(timeAgo(at(-3 * 3600), { now, compact: true })).toBe('3h ago');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('accepts iso strings and timestamps', () => {
|
|
38
|
+
expect(timeAgo('2026-11-09T11:00:00Z', { now })).toBe('1 hour ago');
|
|
39
|
+
expect(timeAgo(now.getTime() - 7200_000, { now })).toBe('2 hours ago');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -28,6 +28,11 @@ export type { PageBounds } from './calculation.utils';
|
|
|
28
28
|
|
|
29
29
|
export { hashSeed } from './hash.util';
|
|
30
30
|
|
|
31
|
+
export { initialsOf, nameHue } from './name.util';
|
|
32
|
+
|
|
33
|
+
export { timeAgo } from './time.util';
|
|
34
|
+
export type { TimeAgoOptions } from './time.util';
|
|
35
|
+
|
|
31
36
|
export { formatCount } from './format.util';
|
|
32
37
|
|
|
33
38
|
export { uniqueId } from './id.util';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { hashSeed } from './hash.util';
|
|
2
|
+
|
|
3
|
+
export const initialsOf = (name: string): string => {
|
|
4
|
+
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
5
|
+
if (parts.length === 0) return '';
|
|
6
|
+
const first = parts[0] ?? '';
|
|
7
|
+
const last = parts[parts.length - 1] ?? '';
|
|
8
|
+
if (parts.length === 1) return first.slice(0, 2).toUpperCase();
|
|
9
|
+
return (first.charAt(0) + last.charAt(0)).toUpperCase();
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const nameHue = (seed: string): number => hashSeed(seed) % 360;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface TimeAgoOptions {
|
|
2
|
+
now?: Date;
|
|
3
|
+
locale?: string;
|
|
4
|
+
compact?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const MINUTE = 60;
|
|
8
|
+
const HOUR = 60 * MINUTE;
|
|
9
|
+
const DAY = 24 * HOUR;
|
|
10
|
+
const WEEK = 7 * DAY;
|
|
11
|
+
const MONTH = 30.44 * DAY;
|
|
12
|
+
const YEAR = 365.25 * DAY;
|
|
13
|
+
|
|
14
|
+
export const timeAgo = (input: Date | string | number, options: TimeAgoOptions = {}): string => {
|
|
15
|
+
const { now = new Date(), locale = 'en', compact = false } = options;
|
|
16
|
+
const date = input instanceof Date ? input : new Date(input);
|
|
17
|
+
const deltaSeconds = Math.round((date.getTime() - now.getTime()) / 1000);
|
|
18
|
+
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: compact ? 'narrow' : 'long' });
|
|
19
|
+
const abs = Math.abs(deltaSeconds);
|
|
20
|
+
if (abs < 45) return formatter.format(0, 'second');
|
|
21
|
+
if (abs < HOUR) return formatter.format(Math.round(deltaSeconds / MINUTE), 'minute');
|
|
22
|
+
if (abs < DAY) return formatter.format(Math.round(deltaSeconds / HOUR), 'hour');
|
|
23
|
+
if (abs < WEEK) return formatter.format(Math.round(deltaSeconds / DAY), 'day');
|
|
24
|
+
if (abs < MONTH) return formatter.format(Math.round(deltaSeconds / WEEK), 'week');
|
|
25
|
+
if (abs < YEAR) return formatter.format(Math.round(deltaSeconds / MONTH), 'month');
|
|
26
|
+
return formatter.format(Math.round(deltaSeconds / YEAR), 'year');
|
|
27
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import { forwardRef, type HTMLAttributes, type ComponentPropsWithoutRef } from 'react';
|
|
3
|
-
import { cn } from '../../infrastructure/utils';
|
|
3
|
+
import { cn, initialsOf } from '../../infrastructure/utils';
|
|
4
4
|
import type { BaseProps } from '../../domain/types';
|
|
5
5
|
import type { ExtendedSizes } from '../../infrastructure/constants';
|
|
6
6
|
|
|
@@ -27,15 +27,6 @@ const nameTones = [
|
|
|
27
27
|
'bg-secondary text-secondary-foreground',
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
-
const initialsOf = (name: string): string => {
|
|
31
|
-
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
32
|
-
if (parts.length === 0) return '';
|
|
33
|
-
const first = parts[0] ?? '';
|
|
34
|
-
const last = parts[parts.length - 1] ?? '';
|
|
35
|
-
if (parts.length === 1) return first.slice(0, 2).toUpperCase();
|
|
36
|
-
return (first.charAt(0) + last.charAt(0)).toUpperCase();
|
|
37
|
-
};
|
|
38
|
-
|
|
39
30
|
const toneIndexOf = (name: string): number => {
|
|
40
31
|
let hash = 0;
|
|
41
32
|
for (let i = 0; i < name.length; i += 1) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
|
|
2
|
+
import { createContext, useCallback, useContext, useRef, useState } from 'react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { ConfirmDialog } from './ConfirmDialog';
|
|
5
|
+
|
|
6
|
+
export interface ConfirmOptions {
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
body?: ReactNode;
|
|
9
|
+
tone?: 'danger' | 'primary' | 'success';
|
|
10
|
+
confirmLabel?: string;
|
|
11
|
+
cancelLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
|
|
15
|
+
|
|
16
|
+
const ConfirmContext = createContext<ConfirmFn | null>(null);
|
|
17
|
+
|
|
18
|
+
export function useConfirm(): ConfirmFn {
|
|
19
|
+
const confirm = useContext(ConfirmContext);
|
|
20
|
+
if (!confirm) throw new Error('useConfirm must be used within ConfirmProvider');
|
|
21
|
+
return confirm;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
|
25
|
+
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
|
26
|
+
const resolver = useRef<((value: boolean) => void) | null>(null);
|
|
27
|
+
|
|
28
|
+
const confirm = useCallback<ConfirmFn>((next) => {
|
|
29
|
+
setOptions(next);
|
|
30
|
+
return new Promise<boolean>((resolve) => {
|
|
31
|
+
resolver.current = resolve;
|
|
32
|
+
});
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
const settle = (value: boolean) => {
|
|
36
|
+
resolver.current?.(value);
|
|
37
|
+
resolver.current = null;
|
|
38
|
+
setOptions(null);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<ConfirmContext.Provider value={confirm}>
|
|
43
|
+
{children}
|
|
44
|
+
<ConfirmDialog
|
|
45
|
+
open={options !== null}
|
|
46
|
+
title={options?.title ?? ''}
|
|
47
|
+
body={options?.body}
|
|
48
|
+
tone={options?.tone ?? 'primary'}
|
|
49
|
+
confirmLabel={options?.confirmLabel}
|
|
50
|
+
cancelLabel={options?.cancelLabel}
|
|
51
|
+
onConfirm={() => settle(true)}
|
|
52
|
+
onClose={() => settle(false)}
|
|
53
|
+
/>
|
|
54
|
+
</ConfirmContext.Provider>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -170,6 +170,9 @@ export type {
|
|
|
170
170
|
export { ConfirmDialog } from './ConfirmDialog';
|
|
171
171
|
export type { ConfirmDialogProps } from './ConfirmDialog';
|
|
172
172
|
|
|
173
|
+
export { ConfirmProvider, useConfirm } from './ConfirmProvider';
|
|
174
|
+
export type { ConfirmOptions, ConfirmFn } from './ConfirmProvider';
|
|
175
|
+
|
|
173
176
|
export { Stepper } from './Stepper';
|
|
174
177
|
export type { StepperProps, StepperStep, StepperStepStatus } from './Stepper';
|
|
175
178
|
|