@sovereignfs/ui 0.23.0 → 0.33.1
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 +14 -2
- 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 +77 -50
- package/dist/DragHandleRow.module.css +9 -3
- package/dist/Drawer.module.css +61 -0
- package/dist/Icon.module.css +5 -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/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/Toast.module.css +4 -2
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +493 -17
- package/dist/index.js +1390 -209
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +13 -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 +14 -2
- package/src/components/Checkbox/Checkbox.tsx +22 -12
- 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 +77 -50
- 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 +9 -3
- 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/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/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/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/Toast/Toast.module.css +4 -2
- 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 +30 -1
- package/src/motion.ts +78 -0
- package/src/stories/DesignSystemOverview.stories.tsx +292 -4
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/TokenGallery.stories.tsx +85 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +13 -0
- package/src/components/Icon/icons/terminal.tsx +0 -19
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
// The platform's canonical mobile breakpoint — matches the shell chrome and
|
|
6
|
+
// Dialog's own full-screen-sheet switch (`runtime/app/globals.css`,
|
|
7
|
+
// `Dialog.module.css`). A plugin whose own layout genuinely needs a different
|
|
8
|
+
// threshold may still hardcode its own value (e.g. a three-column layout that
|
|
9
|
+
// only needs to fork earlier than 768px) — this constant is the *default* every
|
|
10
|
+
// consumer should reach for first, so the platform's idea of "mobile" doesn't
|
|
11
|
+
// silently drift across components again.
|
|
12
|
+
export const MOBILE_BREAKPOINT_PX = 768;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* useIsMobile — whether the viewport is at or below the platform's mobile
|
|
16
|
+
* breakpoint. SSR-safe: defaults to `false` (desktop) until the client mounts
|
|
17
|
+
* and reads the real viewport, avoiding a hydration mismatch on first paint.
|
|
18
|
+
*/
|
|
19
|
+
export function useIsMobile(breakpointPx: number = MOBILE_BREAKPOINT_PX): boolean {
|
|
20
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const mql = window.matchMedia(`(max-width: ${breakpointPx}px)`);
|
|
24
|
+
setIsMobile(mql.matches);
|
|
25
|
+
function handleChange(e: MediaQueryListEvent) {
|
|
26
|
+
setIsMobile(e.matches);
|
|
27
|
+
}
|
|
28
|
+
mql.addEventListener('change', handleChange);
|
|
29
|
+
return () => mql.removeEventListener('change', handleChange);
|
|
30
|
+
}, [breakpointPx]);
|
|
31
|
+
|
|
32
|
+
return isMobile;
|
|
33
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
CSSProperties,
|
|
6
|
+
MouseEvent as ReactMouseEvent,
|
|
7
|
+
PointerEvent as ReactPointerEvent,
|
|
8
|
+
} from 'react';
|
|
9
|
+
|
|
10
|
+
export interface UseLongPressOptions {
|
|
11
|
+
/** Called once the press has been held for `delay` without exceeding `moveTolerance`. */
|
|
12
|
+
onLongPress: () => void;
|
|
13
|
+
/** Hold duration in ms before `onLongPress` fires. Default 500. */
|
|
14
|
+
delay?: number;
|
|
15
|
+
/** Accumulated pointer movement (px) that cancels a pending press. Default 10 —
|
|
16
|
+
* a real finger jitters a couple of px on a still hold; this must be forgiving
|
|
17
|
+
* of that or the gesture never fires. */
|
|
18
|
+
moveTolerance?: number;
|
|
19
|
+
/** How long (ms) after firing to swallow the click that may or may not follow.
|
|
20
|
+
* Default 700. Time-boxed rather than "clear on next click" — iOS frequently
|
|
21
|
+
* sends no click at all after a long hold, so a flag that only clears on the
|
|
22
|
+
* next click stays armed forever and silently eats the user's next real tap. */
|
|
23
|
+
suppressClickMs?: number;
|
|
24
|
+
/** Fire `navigator.vibrate(10)` when the press triggers, where supported. Default true. */
|
|
25
|
+
vibrate?: boolean;
|
|
26
|
+
/** Skip entirely (hooks can't be called conditionally, so this is the escape hatch
|
|
27
|
+
* for a caller whose long-press action isn't available in the current state). */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LongPressHandlers {
|
|
32
|
+
onPointerDown: (e: ReactPointerEvent) => void;
|
|
33
|
+
onPointerMove: (e: ReactPointerEvent) => void;
|
|
34
|
+
onPointerUp: (e: ReactPointerEvent) => void;
|
|
35
|
+
onPointerCancel: (e: ReactPointerEvent) => void;
|
|
36
|
+
onPointerLeave: (e: ReactPointerEvent) => void;
|
|
37
|
+
onContextMenu: (e: ReactMouseEvent) => void;
|
|
38
|
+
onClick: (e: ReactMouseEvent) => void;
|
|
39
|
+
/** Spread onto the target element. Empty on devices whose primary pointer
|
|
40
|
+
* isn't coarse (desktop mouse/trackpad) — see the coarse-pointer gate below. */
|
|
41
|
+
style: CSSProperties;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_DELAY_MS = 500;
|
|
45
|
+
const DEFAULT_MOVE_TOLERANCE_PX = 10;
|
|
46
|
+
const DEFAULT_SUPPRESS_CLICK_MS = 700;
|
|
47
|
+
|
|
48
|
+
// SSR-safe, same defer-to-client-mount pattern as useIsMobile: defaults to
|
|
49
|
+
// false (no suppression styles) until the client can actually read the
|
|
50
|
+
// device's primary pointer, avoiding a hydration mismatch.
|
|
51
|
+
function usePrefersCoarsePointer(): boolean {
|
|
52
|
+
const [coarse, setCoarse] = useState(false);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const mql = window.matchMedia('(pointer: coarse)');
|
|
56
|
+
setCoarse(mql.matches);
|
|
57
|
+
function handleChange(e: MediaQueryListEvent) {
|
|
58
|
+
setCoarse(e.matches);
|
|
59
|
+
}
|
|
60
|
+
mql.addEventListener('change', handleChange);
|
|
61
|
+
return () => mql.removeEventListener('change', handleChange);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
return coarse;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* useLongPress — touch-and-hold gesture with the full suppression recipe a
|
|
69
|
+
* long-press needs to behave, not just a bare timer.
|
|
70
|
+
*
|
|
71
|
+
* A naive `setTimeout` started on `pointerdown` and cleared on `pointermove`
|
|
72
|
+
* fails in three specific ways this hook exists to fix:
|
|
73
|
+
* 1. Any movement at all cancels it — but a real finger jitters a few px even
|
|
74
|
+
* on a "still" hold, so the gesture misfires constantly. `moveTolerance`
|
|
75
|
+
* requires deliberate movement before cancelling.
|
|
76
|
+
* 2. `pointercancel` (the browser converting the touch into a scroll) isn't
|
|
77
|
+
* handled — the timer survives and fires mid-scroll. Handled here.
|
|
78
|
+
* 3. Nothing suppresses the OS's own reaction to a long touch-hold: iOS shows
|
|
79
|
+
* its link-preview callout, Android starts text selection or opens a
|
|
80
|
+
* context menu, and either can fire *alongside* the gesture this hook
|
|
81
|
+
* triggers. `onContextMenu` + the returned `style` (touch-callout,
|
|
82
|
+
* user-select, touch-action) close all three, gated to coarse-pointer
|
|
83
|
+
* devices only — unconditionally disabling `user-select` would break
|
|
84
|
+
* mouse text selection on desktop, which never held this long-press to
|
|
85
|
+
* begin with (see `onPointerDown`'s own `pointerType` check).
|
|
86
|
+
*
|
|
87
|
+
* The click that may or may not follow a fired long-press (iOS often sends
|
|
88
|
+
* none at all) is swallowed for `suppressClickMs`, not indefinitely — an
|
|
89
|
+
* unbounded suppression flag left armed by a browser that never sends that
|
|
90
|
+
* click silently eats the user's *next* unrelated tap on the same element.
|
|
91
|
+
*/
|
|
92
|
+
export function useLongPress({
|
|
93
|
+
onLongPress,
|
|
94
|
+
delay = DEFAULT_DELAY_MS,
|
|
95
|
+
moveTolerance = DEFAULT_MOVE_TOLERANCE_PX,
|
|
96
|
+
suppressClickMs = DEFAULT_SUPPRESS_CLICK_MS,
|
|
97
|
+
vibrate = true,
|
|
98
|
+
disabled = false,
|
|
99
|
+
}: UseLongPressOptions): LongPressHandlers {
|
|
100
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
101
|
+
const startRef = useRef<{ x: number; y: number } | null>(null);
|
|
102
|
+
const suppressUntilRef = useRef(0);
|
|
103
|
+
|
|
104
|
+
const clearTimer = useCallback(() => {
|
|
105
|
+
if (timerRef.current) {
|
|
106
|
+
clearTimeout(timerRef.current);
|
|
107
|
+
timerRef.current = null;
|
|
108
|
+
}
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// A press pending when the component unmounts (its target left the view
|
|
112
|
+
// mid-hold — e.g. the row it's on was deleted by a sync elsewhere) must not
|
|
113
|
+
// fire onLongPress against state that's no longer there.
|
|
114
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
115
|
+
|
|
116
|
+
const onPointerDown = useCallback(
|
|
117
|
+
(e: ReactPointerEvent) => {
|
|
118
|
+
if (disabled || e.pointerType !== 'touch') return;
|
|
119
|
+
startRef.current = { x: e.clientX, y: e.clientY };
|
|
120
|
+
clearTimer();
|
|
121
|
+
timerRef.current = setTimeout(() => {
|
|
122
|
+
timerRef.current = null;
|
|
123
|
+
suppressUntilRef.current = Date.now() + suppressClickMs;
|
|
124
|
+
if (vibrate && typeof navigator !== 'undefined' && navigator.vibrate) {
|
|
125
|
+
navigator.vibrate(10);
|
|
126
|
+
}
|
|
127
|
+
onLongPress();
|
|
128
|
+
}, delay);
|
|
129
|
+
},
|
|
130
|
+
[disabled, delay, suppressClickMs, vibrate, onLongPress, clearTimer],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const onPointerMove = useCallback(
|
|
134
|
+
(e: ReactPointerEvent) => {
|
|
135
|
+
const start = startRef.current;
|
|
136
|
+
if (!start || !timerRef.current) return;
|
|
137
|
+
const dx = e.clientX - start.x;
|
|
138
|
+
const dy = e.clientY - start.y;
|
|
139
|
+
if (Math.hypot(dx, dy) > moveTolerance) clearTimer();
|
|
140
|
+
},
|
|
141
|
+
[moveTolerance, clearTimer],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const cancelPress = useCallback(() => {
|
|
145
|
+
clearTimer();
|
|
146
|
+
startRef.current = null;
|
|
147
|
+
}, [clearTimer]);
|
|
148
|
+
|
|
149
|
+
// Preempts the OS's own long-press menu (Android's link/image context menu)
|
|
150
|
+
// whenever our gesture is pending or has just fired — either way, this
|
|
151
|
+
// hook's action is the one that should win, not a native menu appearing on
|
|
152
|
+
// top of (or instead of) it. iOS's equivalent (the touch-callout link
|
|
153
|
+
// preview) isn't a JS event at all — that one is closed by `style` below.
|
|
154
|
+
const onContextMenu = useCallback((e: ReactMouseEvent) => {
|
|
155
|
+
if (timerRef.current || Date.now() < suppressUntilRef.current) e.preventDefault();
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
const onClick = useCallback((e: ReactMouseEvent) => {
|
|
159
|
+
if (Date.now() < suppressUntilRef.current) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
}
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const isCoarsePointer = usePrefersCoarsePointer();
|
|
166
|
+
const style: CSSProperties = isCoarsePointer
|
|
167
|
+
? {
|
|
168
|
+
WebkitTouchCallout: 'none',
|
|
169
|
+
WebkitUserSelect: 'none',
|
|
170
|
+
userSelect: 'none',
|
|
171
|
+
touchAction: 'manipulation',
|
|
172
|
+
}
|
|
173
|
+
: {};
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
onPointerDown,
|
|
177
|
+
onPointerMove,
|
|
178
|
+
onPointerUp: cancelPress,
|
|
179
|
+
onPointerCancel: cancelPress,
|
|
180
|
+
onPointerLeave: cancelPress,
|
|
181
|
+
onContextMenu,
|
|
182
|
+
onClick,
|
|
183
|
+
style,
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,10 +23,32 @@ export { Input } from './components/Input/Input';
|
|
|
23
23
|
export type { InputProps } from './components/Input/Input';
|
|
24
24
|
export { Textarea } from './components/Textarea/Textarea';
|
|
25
25
|
export type { TextareaProps } from './components/Textarea/Textarea';
|
|
26
|
-
export {
|
|
26
|
+
export { CodeTextarea } from './components/CodeTextarea/CodeTextarea';
|
|
27
|
+
export type { CodeTextareaProps } from './components/CodeTextarea/CodeTextarea';
|
|
28
|
+
export { StatusBadge } from './components/StatusBadge/StatusBadge';
|
|
29
|
+
export type { StatusBadgeProps, StatusBadgeStatus } from './components/StatusBadge/StatusBadge';
|
|
30
|
+
export { SplitPane } from './components/SplitPane/SplitPane';
|
|
31
|
+
export type { SplitPaneProps } from './components/SplitPane/SplitPane';
|
|
32
|
+
export { TagInput } from './components/TagInput/TagInput';
|
|
33
|
+
export type { TagInputProps } from './components/TagInput/TagInput';
|
|
34
|
+
export { Dialog, useOverlaySecondRow } from './components/Dialog/Dialog';
|
|
27
35
|
export type { DialogProps, DialogSize } from './components/Dialog/Dialog';
|
|
28
36
|
export { Drawer } from './components/Drawer/Drawer';
|
|
29
37
|
export type { DrawerProps } from './components/Drawer/Drawer';
|
|
38
|
+
export { OverlayHeader } from './components/OverlayHeader/OverlayHeader';
|
|
39
|
+
export type { OverlayHeaderProps } from './components/OverlayHeader/OverlayHeader';
|
|
40
|
+
export { Sheet } from './components/Sheet/Sheet';
|
|
41
|
+
export type { SheetProps } from './components/Sheet/Sheet';
|
|
42
|
+
export { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog';
|
|
43
|
+
export type { ConfirmDialogProps } from './components/ConfirmDialog/ConfirmDialog';
|
|
44
|
+
export { Menu } from './components/Menu/Menu';
|
|
45
|
+
export type {
|
|
46
|
+
MenuProps,
|
|
47
|
+
MenuItem,
|
|
48
|
+
MenuLabel,
|
|
49
|
+
MenuSeparator,
|
|
50
|
+
MenuEntry,
|
|
51
|
+
} from './components/Menu/Menu';
|
|
30
52
|
export { ToastProvider, useToast } from './components/Toast/Toast';
|
|
31
53
|
export type { ToastItem, ToastContextValue } from './components/Toast/Toast';
|
|
32
54
|
export { Card } from './components/Card/Card';
|
|
@@ -49,3 +71,10 @@ export { Checkbox } from './components/Checkbox/Checkbox';
|
|
|
49
71
|
export type { CheckboxProps } from './components/Checkbox/Checkbox';
|
|
50
72
|
export { DragHandleRow } from './components/DragHandleRow/DragHandleRow';
|
|
51
73
|
export type { DragHandleRowProps } from './components/DragHandleRow/DragHandleRow';
|
|
74
|
+
export { Calendar } from './components/Calendar/Calendar';
|
|
75
|
+
export type { CalendarProps } from './components/Calendar/Calendar';
|
|
76
|
+
export { DatePicker } from './components/DatePicker/DatePicker';
|
|
77
|
+
export type { DatePickerProps } from './components/DatePicker/DatePicker';
|
|
78
|
+
export { useLongPress, useDoubleTapHandler, useSingleOrDoubleTap, useIsMobile } from './hooks';
|
|
79
|
+
export { MOBILE_BREAKPOINT_PX } from './hooks';
|
|
80
|
+
export type { UseLongPressOptions, LongPressHandlers } from './hooks';
|
package/src/motion.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type TransitionPhase = 'entering' | 'open' | 'closing' | 'closed';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* useMountTransition — generic two-phase-mount state machine for animated
|
|
9
|
+
* overlays (Dialog, Drawer, and their Phase-B successors: Sheet,
|
|
10
|
+
* ConfirmDialog, Menu). Internal to the design system — not exported from
|
|
11
|
+
* `index.ts`; each component wires its own CSS classes to the returned phase.
|
|
12
|
+
*
|
|
13
|
+
* The `open` prop controls mounting from the outside, but both directions need
|
|
14
|
+
* two renders to actually animate rather than snap: entering needs a frame at
|
|
15
|
+
* the closed-position styles before flipping to open (so the browser has
|
|
16
|
+
* something to interpolate from), and exiting needs to stay mounted *after*
|
|
17
|
+
* `open` goes false long enough to play in reverse — so `mounted` and `open`
|
|
18
|
+
* diverge for `durationMs` on close:
|
|
19
|
+
*
|
|
20
|
+
* open=true -> phase 'entering' (closed-position styles — this applies
|
|
21
|
+
* equally to a fresh mount that starts open, e.g. a
|
|
22
|
+
* route-driven overlay, and to an existing instance whose
|
|
23
|
+
* `open` prop just flipped true; both should animate in the
|
|
24
|
+
* same way) -> next frame, phase 'open' (open-position styles;
|
|
25
|
+
* the CSS transition between the two animates the entrance).
|
|
26
|
+
* open=false -> phase 'closing' (still mounted, rendered back at the
|
|
27
|
+
* closed-position styles so the transition reverses) -> after
|
|
28
|
+
* durationMs, phase 'closed' (unmounts).
|
|
29
|
+
*
|
|
30
|
+
* A component only needs one conditional class: apply the "open" variant
|
|
31
|
+
* when `phase === 'open'`, and leave the base (closed-position) styles as the
|
|
32
|
+
* unconditional default — entering, closing, and the pre-transition instant
|
|
33
|
+
* all render as that same base state, which is exactly what should animate
|
|
34
|
+
* to/from.
|
|
35
|
+
*/
|
|
36
|
+
export function useMountTransition(
|
|
37
|
+
open: boolean,
|
|
38
|
+
durationMs: number,
|
|
39
|
+
): { mounted: boolean; phase: TransitionPhase } {
|
|
40
|
+
const [phase, setPhase] = useState<TransitionPhase>(open ? 'entering' : 'closed');
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (open) {
|
|
44
|
+
setPhase('entering');
|
|
45
|
+
const raf = requestAnimationFrame(() => setPhase('open'));
|
|
46
|
+
return () => cancelAnimationFrame(raf);
|
|
47
|
+
}
|
|
48
|
+
setPhase((p) => (p === 'closed' ? 'closed' : 'closing'));
|
|
49
|
+
const timer = setTimeout(() => setPhase('closed'), durationMs);
|
|
50
|
+
return () => clearTimeout(timer);
|
|
51
|
+
}, [open, durationMs]);
|
|
52
|
+
|
|
53
|
+
return { mounted: phase !== 'closed', phase };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* usePrefersReducedMotion — SSR-safe (defaults to `false` until the client
|
|
58
|
+
* mounts, matching every other SSR-safe hook in this system). Components pair
|
|
59
|
+
* this with a `durationMs` of ~0 passed to `useMountTransition` and a matching
|
|
60
|
+
* `@media (prefers-reduced-motion: reduce)` CSS rule collapsing the same
|
|
61
|
+
* transition to near-instant — both sides must agree, or the JS unmount timer
|
|
62
|
+
* and the CSS animation duration drift apart.
|
|
63
|
+
*/
|
|
64
|
+
export function usePrefersReducedMotion(): boolean {
|
|
65
|
+
const [reduced, setReduced] = useState(false);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
69
|
+
setReduced(mql.matches);
|
|
70
|
+
function handleChange(e: MediaQueryListEvent) {
|
|
71
|
+
setReduced(e.matches);
|
|
72
|
+
}
|
|
73
|
+
mql.addEventListener('change', handleChange);
|
|
74
|
+
return () => mql.removeEventListener('change', handleChange);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
return reduced;
|
|
78
|
+
}
|