@sovereignfs/ui 0.21.3 → 0.33.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/dist/Button.module.css +75 -8
- package/dist/Calendar.module.css +126 -0
- package/dist/Card.module.css +4 -2
- package/dist/Checkbox.module.css +109 -0
- package/dist/CodeTextarea.module.css +53 -0
- package/dist/ConfirmDialog.module.css +109 -0
- package/dist/DatePicker.module.css +57 -0
- package/dist/Dialog.module.css +73 -46
- package/dist/DragHandleRow.module.css +57 -0
- package/dist/Drawer.module.css +61 -0
- package/dist/FormField.module.css +5 -0
- package/dist/Icon.module.css +5 -0
- package/dist/Input.module.css +10 -0
- package/dist/Menu.module.css +82 -0
- package/dist/NavTabs.module.css +4 -2
- package/dist/OverlayHeader.module.css +98 -0
- package/dist/Popover.module.css +31 -1
- package/dist/SegmentedControl.module.css +4 -2
- package/dist/Select.module.css +9 -0
- package/dist/Sheet.module.css +55 -0
- package/dist/SplitPane.module.css +85 -0
- package/dist/StatusBadge.module.css +59 -0
- package/dist/SystemBanner.module.css +4 -2
- package/dist/Tabs.module.css +4 -2
- package/dist/TagInput.module.css +141 -0
- package/dist/Textarea.module.css +39 -0
- package/dist/Toast.module.css +7 -5
- package/dist/Toggle.module.css +1 -1
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +549 -20
- package/dist/index.js +1496 -193
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +19 -0
- package/package.json +1 -1
- package/src/__tests__/motion.test.tsx +105 -0
- package/src/components/Button/Button.module.css +75 -8
- package/src/components/Button/Button.stories.tsx +6 -0
- package/src/components/Button/Button.tsx +17 -1
- package/src/components/Button/__tests__/Button.test.tsx +19 -0
- package/src/components/Calendar/Calendar.module.css +126 -0
- package/src/components/Calendar/Calendar.tsx +213 -0
- package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
- package/src/components/Calendar/dateUtils.ts +97 -0
- package/src/components/Card/Card.module.css +4 -2
- package/src/components/Checkbox/Checkbox.module.css +109 -0
- package/src/components/Checkbox/Checkbox.tsx +78 -0
- package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
- package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
- package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
- package/src/components/DatePicker/DatePicker.module.css +57 -0
- package/src/components/DatePicker/DatePicker.tsx +103 -0
- package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
- package/src/components/Dialog/Dialog.module.css +73 -46
- package/src/components/Dialog/Dialog.stories.tsx +51 -1
- package/src/components/Dialog/Dialog.tsx +86 -22
- package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
- package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
- package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
- package/src/components/Drawer/Drawer.module.css +61 -0
- package/src/components/Drawer/Drawer.tsx +117 -13
- package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
- package/src/components/FormField/FormField.module.css +5 -0
- package/src/components/FormField/FormField.tsx +36 -11
- package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
- package/src/components/Icon/Icon.module.css +5 -0
- package/src/components/Icon/Icon.stories.tsx +4 -3
- package/src/components/Icon/Icon.tsx +1 -1
- package/src/components/Icon/icons/calendar.tsx +23 -0
- package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -4
- package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
- package/src/components/Input/Input.module.css +10 -0
- package/src/components/Menu/Menu.module.css +82 -0
- package/src/components/Menu/Menu.tsx +137 -0
- package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
- package/src/components/NavTabs/NavTabs.module.css +4 -2
- package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
- package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
- package/src/components/Popover/Popover.module.css +31 -1
- package/src/components/Popover/Popover.stories.tsx +126 -1
- package/src/components/Popover/Popover.tsx +140 -7
- package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
- package/src/components/Select/Select.module.css +9 -0
- package/src/components/Select/Select.tsx +10 -2
- package/src/components/Sheet/Sheet.module.css +55 -0
- package/src/components/Sheet/Sheet.tsx +151 -0
- package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
- package/src/components/SplitPane/SplitPane.module.css +85 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
- package/src/components/SplitPane/SplitPane.tsx +149 -0
- package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
- package/src/components/StatusBadge/StatusBadge.module.css +59 -0
- package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
- package/src/components/StatusBadge/StatusBadge.tsx +65 -0
- package/src/components/SystemBanner/SystemBanner.module.css +4 -2
- package/src/components/Tabs/Tabs.module.css +4 -2
- package/src/components/TagInput/TagInput.module.css +141 -0
- package/src/components/TagInput/TagInput.stories.tsx +109 -0
- package/src/components/TagInput/TagInput.tsx +174 -0
- package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
- package/src/components/Textarea/Textarea.module.css +39 -0
- package/src/components/Textarea/Textarea.tsx +15 -0
- package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
- package/src/components/Toast/Toast.module.css +7 -5
- package/src/components/Toggle/Toggle.module.css +1 -1
- package/src/components/Tooltip/Tooltip.module.css +6 -1
- package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
- package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
- package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useDoubleTap.ts +91 -0
- package/src/hooks/useIsMobile.ts +33 -0
- package/src/hooks/useLongPress.ts +185 -0
- package/src/index.ts +37 -2
- package/src/motion.ts +78 -0
- package/src/stories/Checkbox.stories.tsx +45 -0
- package/src/stories/DesignSystemOverview.stories.tsx +354 -8
- package/src/stories/DragHandleRow.stories.tsx +41 -0
- package/src/stories/FormField.stories.tsx +8 -8
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/MobilePatterns.stories.tsx +4 -4
- package/src/stories/Textarea.stories.tsx +51 -0
- package/src/stories/TokenGallery.stories.tsx +87 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +19 -0
- package/src/components/Icon/icons/terminal.tsx +0 -19
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { TagInput } from '../TagInput';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('TagInput', () => {
|
|
9
|
+
it('adds a tag with Enter', () => {
|
|
10
|
+
const onChange = vi.fn();
|
|
11
|
+
render(<TagInput value={[]} onChange={onChange} aria-label="Tags" />);
|
|
12
|
+
|
|
13
|
+
const input = screen.getByLabelText('Tags');
|
|
14
|
+
fireEvent.change(input, { target: { value: 'launch' } });
|
|
15
|
+
fireEvent.keyDown(input, { key: 'Enter' });
|
|
16
|
+
|
|
17
|
+
expect(onChange).toHaveBeenCalledWith(['launch']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('removes the last tag with Backspace when the input is empty', () => {
|
|
21
|
+
const onChange = vi.fn();
|
|
22
|
+
render(<TagInput value={['draft', 'publish']} onChange={onChange} aria-label="Tags" />);
|
|
23
|
+
|
|
24
|
+
fireEvent.keyDown(screen.getByLabelText('Tags'), { key: 'Backspace' });
|
|
25
|
+
|
|
26
|
+
expect(onChange).toHaveBeenCalledWith(['draft']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('splits pasted comma-separated tags', () => {
|
|
30
|
+
const onChange = vi.fn();
|
|
31
|
+
render(<TagInput value={['draft']} onChange={onChange} aria-label="Tags" />);
|
|
32
|
+
|
|
33
|
+
fireEvent.paste(screen.getByLabelText('Tags'), {
|
|
34
|
+
clipboardData: {
|
|
35
|
+
getData: () => 'launch, release\nnotes',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(onChange).toHaveBeenCalledWith(['draft', 'launch', 'release', 'notes']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('shows validation messages and does not add invalid tags', () => {
|
|
43
|
+
const onChange = vi.fn();
|
|
44
|
+
render(
|
|
45
|
+
<TagInput
|
|
46
|
+
value={[]}
|
|
47
|
+
onChange={onChange}
|
|
48
|
+
aria-label="Tags"
|
|
49
|
+
validateTag={(tag) => (tag.length > 4 ? 'Tag is too long.' : undefined)}
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const input = screen.getByLabelText('Tags');
|
|
54
|
+
fireEvent.change(input, { target: { value: 'launch' } });
|
|
55
|
+
fireEvent.keyDown(input, { key: 'Enter' });
|
|
56
|
+
|
|
57
|
+
expect(screen.getByRole('alert').textContent).toBe('Tag is too long.');
|
|
58
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders removable chips with accessible names', () => {
|
|
62
|
+
const onChange = vi.fn();
|
|
63
|
+
render(<TagInput value={['draft']} onChange={onChange} aria-label="Tags" />);
|
|
64
|
+
|
|
65
|
+
fireEvent.click(screen.getByRole('button', { name: 'Remove draft' }));
|
|
66
|
+
|
|
67
|
+
expect(onChange).toHaveBeenCalledWith([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
.textarea {
|
|
2
|
+
display: block;
|
|
3
|
+
width: 100%;
|
|
4
|
+
padding: var(--sv-space-2) var(--sv-space-3);
|
|
5
|
+
font-family: var(--sv-font-family);
|
|
6
|
+
font-size: var(--sv-font-size-sm);
|
|
7
|
+
line-height: 1.5;
|
|
8
|
+
color: var(--sv-color-text-primary);
|
|
9
|
+
background-color: var(--sv-color-surface);
|
|
10
|
+
border: 1px solid var(--sv-color-border-strong);
|
|
11
|
+
border-radius: var(--sv-radius-md);
|
|
12
|
+
resize: vertical;
|
|
13
|
+
transition:
|
|
14
|
+
border-color 0.15s ease,
|
|
15
|
+
box-shadow 0.15s ease;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.textarea::placeholder {
|
|
19
|
+
color: var(--sv-color-text-muted);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.textarea:focus-visible {
|
|
23
|
+
outline: none;
|
|
24
|
+
border-color: var(--sv-color-accent);
|
|
25
|
+
box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.textarea:read-only {
|
|
29
|
+
background-color: var(--sv-color-surface-sunken);
|
|
30
|
+
color: var(--sv-color-text-muted);
|
|
31
|
+
border-color: var(--sv-color-border);
|
|
32
|
+
cursor: default;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.textarea:disabled {
|
|
36
|
+
opacity: 0.5;
|
|
37
|
+
cursor: not-allowed;
|
|
38
|
+
resize: none;
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TextareaHTMLAttributes } from 'react';
|
|
2
|
+
import styles from './Textarea.module.css';
|
|
3
|
+
|
|
4
|
+
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Textarea — the primitive multi-line text field. Presentational and
|
|
8
|
+
* RSC-safe: it forwards all native textarea props to the underlying
|
|
9
|
+
* `<textarea>`. Styling references `--sv-*` tokens via CSS Modules; there
|
|
10
|
+
* are no hardcoded values.
|
|
11
|
+
*/
|
|
12
|
+
export function Textarea({ className, rows = 4, ...rest }: TextareaProps) {
|
|
13
|
+
const classes = [styles.textarea, className].filter(Boolean).join(' ');
|
|
14
|
+
return <textarea rows={rows} className={classes} {...rest} />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Textarea } from '../Textarea';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Textarea', () => {
|
|
9
|
+
it('renders and forwards native props', () => {
|
|
10
|
+
render(<Textarea placeholder="Bio" defaultValue="Hello" />);
|
|
11
|
+
const textarea = screen.getByPlaceholderText('Bio') as HTMLTextAreaElement;
|
|
12
|
+
expect(textarea.value).toBe('Hello');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('defaults to 4 rows', () => {
|
|
16
|
+
render(<Textarea aria-label="field" />);
|
|
17
|
+
expect((screen.getByLabelText('field') as HTMLTextAreaElement).rows).toBe(4);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('honours an explicit rows value', () => {
|
|
21
|
+
render(<Textarea aria-label="field" rows={8} />);
|
|
22
|
+
expect((screen.getByLabelText('field') as HTMLTextAreaElement).rows).toBe(8);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('is disabled when the disabled prop is set', () => {
|
|
26
|
+
render(<Textarea aria-label="field" disabled />);
|
|
27
|
+
expect((screen.getByLabelText('field') as HTMLTextAreaElement).disabled).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
|
|
81
81
|
.message {
|
|
82
82
|
font-size: var(--sv-font-size-sm);
|
|
83
|
-
color: var(--sv-color-text-
|
|
83
|
+
color: var(--sv-color-text-muted);
|
|
84
84
|
margin-top: var(--sv-space-1);
|
|
85
85
|
line-height: 1.4;
|
|
86
86
|
}
|
|
@@ -91,13 +91,15 @@
|
|
|
91
91
|
border: none;
|
|
92
92
|
padding: 0;
|
|
93
93
|
cursor: pointer;
|
|
94
|
-
color: var(--sv-color-text-
|
|
94
|
+
color: var(--sv-color-text-subtle);
|
|
95
95
|
line-height: 1;
|
|
96
|
-
font-size: var(--sv-font-size-
|
|
96
|
+
font-size: var(--sv-font-size-md);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
@media (hover: hover) {
|
|
100
|
+
.close:hover {
|
|
101
|
+
color: var(--sv-color-text-primary);
|
|
102
|
+
}
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
@keyframes toastIn {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
4
|
+
import { useDoubleTapHandler, useSingleOrDoubleTap } from '../useDoubleTap';
|
|
5
|
+
|
|
6
|
+
// A minimal harness: the hook returns a handler bound to onClick. Real
|
|
7
|
+
// double-clicks report detail===2; two separate taps report detail===1 each.
|
|
8
|
+
function DoubleTapHarness({ onDouble }: { onDouble: () => void }) {
|
|
9
|
+
const handle = useDoubleTapHandler<React.MouseEvent>(() => onDouble());
|
|
10
|
+
return (
|
|
11
|
+
<button data-testid="btn" onClick={handle}>
|
|
12
|
+
tap
|
|
13
|
+
</button>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function SingleOrDoubleHarness({
|
|
18
|
+
onSingle,
|
|
19
|
+
onDouble,
|
|
20
|
+
}: {
|
|
21
|
+
onSingle: () => void;
|
|
22
|
+
onDouble: () => void;
|
|
23
|
+
}) {
|
|
24
|
+
const handle = useSingleOrDoubleTap<React.MouseEvent>(
|
|
25
|
+
() => onSingle(),
|
|
26
|
+
() => onDouble(),
|
|
27
|
+
);
|
|
28
|
+
return (
|
|
29
|
+
<button data-testid="btn" onClick={handle}>
|
|
30
|
+
tap
|
|
31
|
+
</button>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useDoubleTapHandler', () => {
|
|
36
|
+
beforeEach(() => vi.useFakeTimers());
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.useRealTimers();
|
|
39
|
+
cleanup();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('fires immediately on a native double-click (detail === 2)', () => {
|
|
43
|
+
const onDouble = vi.fn();
|
|
44
|
+
const { getByTestId } = render(<DoubleTapHarness onDouble={onDouble} />);
|
|
45
|
+
fireEvent.click(getByTestId('btn'), { detail: 2 });
|
|
46
|
+
expect(onDouble).toHaveBeenCalledOnce();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fires on two touch taps within the window', () => {
|
|
50
|
+
const onDouble = vi.fn();
|
|
51
|
+
const { getByTestId } = render(<DoubleTapHarness onDouble={onDouble} />);
|
|
52
|
+
const btn = getByTestId('btn');
|
|
53
|
+
fireEvent.click(btn, { detail: 1 });
|
|
54
|
+
act(() => void vi.advanceTimersByTime(100));
|
|
55
|
+
fireEvent.click(btn, { detail: 1 });
|
|
56
|
+
expect(onDouble).toHaveBeenCalledOnce();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not fire on a single tap', () => {
|
|
60
|
+
const onDouble = vi.fn();
|
|
61
|
+
const { getByTestId } = render(<DoubleTapHarness onDouble={onDouble} />);
|
|
62
|
+
fireEvent.click(getByTestId('btn'), { detail: 1 });
|
|
63
|
+
act(() => void vi.advanceTimersByTime(1000));
|
|
64
|
+
expect(onDouble).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('useSingleOrDoubleTap', () => {
|
|
69
|
+
beforeEach(() => vi.useFakeTimers());
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
cleanup();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('defers the single action until the double-tap window closes', () => {
|
|
76
|
+
const onSingle = vi.fn();
|
|
77
|
+
const onDouble = vi.fn();
|
|
78
|
+
const { getByTestId } = render(
|
|
79
|
+
<SingleOrDoubleHarness onSingle={onSingle} onDouble={onDouble} />,
|
|
80
|
+
);
|
|
81
|
+
fireEvent.click(getByTestId('btn'), { detail: 1 });
|
|
82
|
+
// Single must NOT have fired yet — this deferral is the whole point.
|
|
83
|
+
expect(onSingle).not.toHaveBeenCalled();
|
|
84
|
+
act(() => void vi.advanceTimersByTime(350));
|
|
85
|
+
expect(onSingle).toHaveBeenCalledOnce();
|
|
86
|
+
expect(onDouble).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('fires double (and never single) when a second tap arrives in time', () => {
|
|
90
|
+
const onSingle = vi.fn();
|
|
91
|
+
const onDouble = vi.fn();
|
|
92
|
+
const { getByTestId } = render(
|
|
93
|
+
<SingleOrDoubleHarness onSingle={onSingle} onDouble={onDouble} />,
|
|
94
|
+
);
|
|
95
|
+
const btn = getByTestId('btn');
|
|
96
|
+
fireEvent.click(btn, { detail: 1 });
|
|
97
|
+
act(() => void vi.advanceTimersByTime(100));
|
|
98
|
+
fireEvent.click(btn, { detail: 1 });
|
|
99
|
+
act(() => void vi.advanceTimersByTime(500));
|
|
100
|
+
expect(onDouble).toHaveBeenCalledOnce();
|
|
101
|
+
expect(onSingle).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('fires double on a native double-click and cancels the pending single', () => {
|
|
105
|
+
const onSingle = vi.fn();
|
|
106
|
+
const onDouble = vi.fn();
|
|
107
|
+
const { getByTestId } = render(
|
|
108
|
+
<SingleOrDoubleHarness onSingle={onSingle} onDouble={onDouble} />,
|
|
109
|
+
);
|
|
110
|
+
const btn = getByTestId('btn');
|
|
111
|
+
fireEvent.click(btn, { detail: 1 });
|
|
112
|
+
fireEvent.click(btn, { detail: 2 });
|
|
113
|
+
act(() => void vi.advanceTimersByTime(500));
|
|
114
|
+
expect(onDouble).toHaveBeenCalledOnce();
|
|
115
|
+
expect(onSingle).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { MOBILE_BREAKPOINT_PX, useIsMobile } from '../useIsMobile';
|
|
5
|
+
|
|
6
|
+
// Capture the registered change listener so a test can drive a viewport change.
|
|
7
|
+
let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
|
|
8
|
+
let matches = false;
|
|
9
|
+
let lastQuery = '';
|
|
10
|
+
|
|
11
|
+
function installMatchMedia() {
|
|
12
|
+
changeHandler = null;
|
|
13
|
+
vi.stubGlobal(
|
|
14
|
+
'matchMedia',
|
|
15
|
+
vi.fn().mockImplementation((query: string) => {
|
|
16
|
+
lastQuery = query;
|
|
17
|
+
return {
|
|
18
|
+
matches,
|
|
19
|
+
media: query,
|
|
20
|
+
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
21
|
+
changeHandler = cb;
|
|
22
|
+
},
|
|
23
|
+
removeEventListener: vi.fn(),
|
|
24
|
+
addListener: vi.fn(),
|
|
25
|
+
removeListener: vi.fn(),
|
|
26
|
+
dispatchEvent: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Harness({ breakpoint }: { breakpoint?: number }) {
|
|
33
|
+
const isMobile = useIsMobile(breakpoint);
|
|
34
|
+
return <span data-testid="out">{String(isMobile)}</span>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('useIsMobile', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
matches = false;
|
|
40
|
+
installMatchMedia();
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('exports the canonical 768px breakpoint', () => {
|
|
48
|
+
expect(MOBILE_BREAKPOINT_PX).toBe(768);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('defaults to the canonical breakpoint in its query', () => {
|
|
52
|
+
matches = true;
|
|
53
|
+
installMatchMedia();
|
|
54
|
+
render(<Harness />);
|
|
55
|
+
expect(lastQuery).toBe('(max-width: 768px)');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('reflects the initial matchMedia result after mount', () => {
|
|
59
|
+
matches = true;
|
|
60
|
+
installMatchMedia();
|
|
61
|
+
const { getByTestId } = render(<Harness />);
|
|
62
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('updates when the viewport crosses the breakpoint', () => {
|
|
66
|
+
matches = false;
|
|
67
|
+
installMatchMedia();
|
|
68
|
+
const { getByTestId } = render(<Harness />);
|
|
69
|
+
expect(getByTestId('out').textContent).toBe('false');
|
|
70
|
+
act(() => changeHandler?.({ matches: true } as MediaQueryListEvent));
|
|
71
|
+
expect(getByTestId('out').textContent).toBe('true');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('honours a custom breakpoint override', () => {
|
|
75
|
+
matches = false;
|
|
76
|
+
installMatchMedia();
|
|
77
|
+
render(<Harness breakpoint={640} />);
|
|
78
|
+
expect(lastQuery).toBe('(max-width: 640px)');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
|
4
|
+
import { useLongPress, type UseLongPressOptions } from '../useLongPress';
|
|
5
|
+
|
|
6
|
+
// Configurable matchMedia mock — usePrefersCoarsePointer reads
|
|
7
|
+
// '(pointer: coarse)', and the SSR-safe hooks read it on mount. Default to a
|
|
8
|
+
// coarse (touch) device so the suppression styles are exercised; individual
|
|
9
|
+
// tests override `coarse` where they assert the desktop path.
|
|
10
|
+
let coarse = true;
|
|
11
|
+
function installMatchMedia() {
|
|
12
|
+
vi.stubGlobal(
|
|
13
|
+
'matchMedia',
|
|
14
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
15
|
+
matches: query.includes('pointer: coarse') ? coarse : false,
|
|
16
|
+
media: query,
|
|
17
|
+
addEventListener: vi.fn(),
|
|
18
|
+
removeEventListener: vi.fn(),
|
|
19
|
+
addListener: vi.fn(),
|
|
20
|
+
removeListener: vi.fn(),
|
|
21
|
+
dispatchEvent: vi.fn(),
|
|
22
|
+
})),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A tiny harness component: spreads the hook's handlers onto a div so we can
|
|
27
|
+
// fire real pointer events at it via Testing Library.
|
|
28
|
+
function Harness(props: UseLongPressOptions & { testId?: string }) {
|
|
29
|
+
const { testId = 'target', ...options } = props;
|
|
30
|
+
const handlers = useLongPress(options);
|
|
31
|
+
return (
|
|
32
|
+
<div data-testid={testId} {...handlers}>
|
|
33
|
+
target
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pointer(type: 'touch' | 'mouse', x = 0, y = 0) {
|
|
39
|
+
return { pointerType: type, clientX: x, clientY: y };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('useLongPress', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
coarse = true;
|
|
45
|
+
installMatchMedia();
|
|
46
|
+
vi.useFakeTimers();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.runOnlyPendingTimers();
|
|
51
|
+
vi.useRealTimers();
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
cleanup();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('fires onLongPress after the delay on a touch press held still', () => {
|
|
57
|
+
const onLongPress = vi.fn();
|
|
58
|
+
const { getByTestId } = render(<Harness onLongPress={onLongPress} delay={500} />);
|
|
59
|
+
fireEvent.pointerDown(getByTestId('target'), pointer('touch'));
|
|
60
|
+
expect(onLongPress).not.toHaveBeenCalled();
|
|
61
|
+
act(() => void vi.advanceTimersByTime(500));
|
|
62
|
+
expect(onLongPress).toHaveBeenCalledOnce();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not arm for a mouse pointer (desktop keeps its own affordance)', () => {
|
|
66
|
+
const onLongPress = vi.fn();
|
|
67
|
+
const { getByTestId } = render(<Harness onLongPress={onLongPress} delay={500} />);
|
|
68
|
+
fireEvent.pointerDown(getByTestId('target'), pointer('mouse'));
|
|
69
|
+
act(() => void vi.advanceTimersByTime(1000));
|
|
70
|
+
expect(onLongPress).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('tolerates finger jitter within moveTolerance', () => {
|
|
74
|
+
const onLongPress = vi.fn();
|
|
75
|
+
const { getByTestId } = render(
|
|
76
|
+
<Harness onLongPress={onLongPress} delay={500} moveTolerance={10} />,
|
|
77
|
+
);
|
|
78
|
+
const el = getByTestId('target');
|
|
79
|
+
fireEvent.pointerDown(el, pointer('touch', 0, 0));
|
|
80
|
+
fireEvent.pointerMove(el, pointer('touch', 4, 4)); // ~5.7px < 10
|
|
81
|
+
act(() => void vi.advanceTimersByTime(500));
|
|
82
|
+
expect(onLongPress).toHaveBeenCalledOnce();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('cancels when movement exceeds moveTolerance', () => {
|
|
86
|
+
const onLongPress = vi.fn();
|
|
87
|
+
const { getByTestId } = render(
|
|
88
|
+
<Harness onLongPress={onLongPress} delay={500} moveTolerance={10} />,
|
|
89
|
+
);
|
|
90
|
+
const el = getByTestId('target');
|
|
91
|
+
fireEvent.pointerDown(el, pointer('touch', 0, 0));
|
|
92
|
+
fireEvent.pointerMove(el, pointer('touch', 20, 0)); // 20px > 10
|
|
93
|
+
act(() => void vi.advanceTimersByTime(500));
|
|
94
|
+
expect(onLongPress).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('cancels on pointercancel (touch converted to scroll)', () => {
|
|
98
|
+
const onLongPress = vi.fn();
|
|
99
|
+
const { getByTestId } = render(<Harness onLongPress={onLongPress} delay={500} />);
|
|
100
|
+
const el = getByTestId('target');
|
|
101
|
+
fireEvent.pointerDown(el, pointer('touch'));
|
|
102
|
+
fireEvent.pointerCancel(el, pointer('touch'));
|
|
103
|
+
act(() => void vi.advanceTimersByTime(500));
|
|
104
|
+
expect(onLongPress).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('suppresses the click that may follow, but only for a bounded window', () => {
|
|
108
|
+
const onLongPress = vi.fn();
|
|
109
|
+
const { getByTestId } = render(
|
|
110
|
+
<Harness onLongPress={onLongPress} delay={500} suppressClickMs={700} />,
|
|
111
|
+
);
|
|
112
|
+
const el = getByTestId('target');
|
|
113
|
+
fireEvent.pointerDown(el, pointer('touch'));
|
|
114
|
+
act(() => void vi.advanceTimersByTime(500));
|
|
115
|
+
expect(onLongPress).toHaveBeenCalledOnce();
|
|
116
|
+
|
|
117
|
+
// A click arriving right after the long-press is swallowed.
|
|
118
|
+
const suppressed = fireEvent.click(el);
|
|
119
|
+
expect(suppressed).toBe(false); // preventDefault called → fireEvent returns false
|
|
120
|
+
|
|
121
|
+
// After the suppression window, a genuinely unrelated later tap is NOT eaten.
|
|
122
|
+
act(() => void vi.advanceTimersByTime(800));
|
|
123
|
+
const allowed = fireEvent.click(el);
|
|
124
|
+
expect(allowed).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('prevents the OS context menu while a press is pending', () => {
|
|
128
|
+
const { getByTestId } = render(<Harness onLongPress={vi.fn()} delay={500} />);
|
|
129
|
+
const el = getByTestId('target');
|
|
130
|
+
fireEvent.pointerDown(el, pointer('touch'));
|
|
131
|
+
const prevented = fireEvent.contextMenu(el);
|
|
132
|
+
expect(prevented).toBe(false); // preventDefault called
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does nothing when disabled', () => {
|
|
136
|
+
const onLongPress = vi.fn();
|
|
137
|
+
const { getByTestId } = render(<Harness onLongPress={onLongPress} delay={500} disabled />);
|
|
138
|
+
fireEvent.pointerDown(getByTestId('target'), pointer('touch'));
|
|
139
|
+
act(() => void vi.advanceTimersByTime(500));
|
|
140
|
+
expect(onLongPress).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('emits suppression styles only on coarse-pointer devices', () => {
|
|
144
|
+
coarse = true;
|
|
145
|
+
installMatchMedia();
|
|
146
|
+
const { getByTestId, rerender } = render(<Harness onLongPress={vi.fn()} testId="coarse" />);
|
|
147
|
+
// jsdom applies the inline style object; user-select none is the tell.
|
|
148
|
+
expect(getByTestId('coarse').style.userSelect).toBe('none');
|
|
149
|
+
|
|
150
|
+
coarse = false;
|
|
151
|
+
installMatchMedia();
|
|
152
|
+
rerender(<Harness onLongPress={vi.fn()} testId="coarse" />);
|
|
153
|
+
// A remount picks up the new (fine-pointer) matchMedia result.
|
|
154
|
+
cleanup();
|
|
155
|
+
const fine = render(<Harness onLongPress={vi.fn()} testId="fine" />);
|
|
156
|
+
expect(fine.getByTestId('fine').style.userSelect).toBe('');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
// Real double-clicks (desktop mouse) report e.detail === 2 natively — no extra
|
|
6
|
+
// work needed there. Touch-generated click events always report detail === 1
|
|
7
|
+
// (no browser synthesizes a dblclick-style count from two taps), so
|
|
8
|
+
// double-tap has to be detected by timing two clicks against each other
|
|
9
|
+
// instead. Sharing one implementation for both means every double-click-style
|
|
10
|
+
// affordance behaves the same way on mouse and touch without the caller
|
|
11
|
+
// duplicating the two code paths.
|
|
12
|
+
const DOUBLE_TAP_MS = 350;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* useDoubleTapHandler — fires `onDoubleTap` on a real double-click (desktop)
|
|
16
|
+
* or two taps within `DOUBLE_TAP_MS` (touch).
|
|
17
|
+
*
|
|
18
|
+
* Only safe when the *single* tap/click has no default action of its own to
|
|
19
|
+
* preempt — e.g. a colour swatch with nothing to cancel. If the single tap
|
|
20
|
+
* navigates, opens something, or otherwise does something a following double
|
|
21
|
+
* needs to be able to undo before it happens, use `useSingleOrDoubleTap`
|
|
22
|
+
* instead: this hook fires `onDoubleTap` only, leaving any single-tap handling
|
|
23
|
+
* to the caller's own `onClick`, which already ran immediately and can't be
|
|
24
|
+
* un-done here.
|
|
25
|
+
*
|
|
26
|
+
* The event is passed through to `onDoubleTap` rather than calling
|
|
27
|
+
* `preventDefault` internally — not every call site needs to cancel
|
|
28
|
+
* something, so the decision is left to the caller.
|
|
29
|
+
*/
|
|
30
|
+
export function useDoubleTapHandler<E extends { detail: number }>(
|
|
31
|
+
onDoubleTap: (e: E) => void,
|
|
32
|
+
): (e: E) => void {
|
|
33
|
+
const lastTime = useRef(0);
|
|
34
|
+
return (e: E) => {
|
|
35
|
+
if (e.detail === 2) {
|
|
36
|
+
onDoubleTap(e);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
if (now - lastTime.current < DOUBLE_TAP_MS) {
|
|
41
|
+
lastTime.current = 0;
|
|
42
|
+
onDoubleTap(e);
|
|
43
|
+
} else {
|
|
44
|
+
lastTime.current = now;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* useSingleOrDoubleTap — for call sites where the single tap/click *does*
|
|
51
|
+
* have a default action (e.g. navigating) that a following double-tap must be
|
|
52
|
+
* able to preempt.
|
|
53
|
+
*
|
|
54
|
+
* A real double-click gets away with firing the single action immediately and
|
|
55
|
+
* only detecting the double afterward, because `e.detail === 2` is the
|
|
56
|
+
* browser's own resolved signal, arriving on the very click that matters. A
|
|
57
|
+
* touch double-tap has no equivalent "hold on, there might be a second one"
|
|
58
|
+
* signal — the only way to know is to wait out the window before committing
|
|
59
|
+
* to the single action, which is what this hook does: `onSingle` is deferred
|
|
60
|
+
* by `DOUBLE_TAP_MS` and only actually runs if no second tap arrives in that
|
|
61
|
+
* time. This means every single tap through this hook incurs that latency —
|
|
62
|
+
* only use it where a genuine double-tap gesture must be able to preempt the
|
|
63
|
+
* single action; `useDoubleTapHandler` above has no such delay.
|
|
64
|
+
*/
|
|
65
|
+
export function useSingleOrDoubleTap<E extends { detail: number }>(
|
|
66
|
+
onSingle: (e: E) => void,
|
|
67
|
+
onDouble: (e: E) => void,
|
|
68
|
+
): (e: E) => void {
|
|
69
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
70
|
+
|
|
71
|
+
return (e: E) => {
|
|
72
|
+
if (e.detail === 2) {
|
|
73
|
+
if (timer.current) {
|
|
74
|
+
clearTimeout(timer.current);
|
|
75
|
+
timer.current = null;
|
|
76
|
+
}
|
|
77
|
+
onDouble(e);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (timer.current) {
|
|
81
|
+
clearTimeout(timer.current);
|
|
82
|
+
timer.current = null;
|
|
83
|
+
onDouble(e);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
timer.current = setTimeout(() => {
|
|
87
|
+
timer.current = null;
|
|
88
|
+
onSingle(e);
|
|
89
|
+
}, DOUBLE_TAP_MS);
|
|
90
|
+
};
|
|
91
|
+
}
|