@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.
Files changed (109) hide show
  1. package/dist/Button.module.css +75 -8
  2. package/dist/Calendar.module.css +126 -0
  3. package/dist/Card.module.css +4 -2
  4. package/dist/Checkbox.module.css +14 -2
  5. package/dist/CodeTextarea.module.css +53 -0
  6. package/dist/ConfirmDialog.module.css +109 -0
  7. package/dist/DatePicker.module.css +57 -0
  8. package/dist/Dialog.module.css +77 -50
  9. package/dist/DragHandleRow.module.css +9 -3
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/Icon.module.css +5 -0
  12. package/dist/Menu.module.css +82 -0
  13. package/dist/NavTabs.module.css +4 -2
  14. package/dist/OverlayHeader.module.css +98 -0
  15. package/dist/Popover.module.css +31 -1
  16. package/dist/SegmentedControl.module.css +4 -2
  17. package/dist/Sheet.module.css +55 -0
  18. package/dist/SplitPane.module.css +85 -0
  19. package/dist/StatusBadge.module.css +59 -0
  20. package/dist/SystemBanner.module.css +4 -2
  21. package/dist/Tabs.module.css +4 -2
  22. package/dist/TagInput.module.css +141 -0
  23. package/dist/Toast.module.css +4 -2
  24. package/dist/Tooltip.module.css +6 -1
  25. package/dist/index.d.ts +493 -17
  26. package/dist/index.js +1390 -209
  27. package/dist/tokens/primitives.css +19 -0
  28. package/dist/tokens/semantic.css +13 -0
  29. package/package.json +1 -1
  30. package/src/__tests__/motion.test.tsx +105 -0
  31. package/src/components/Button/Button.module.css +75 -8
  32. package/src/components/Button/Button.stories.tsx +6 -0
  33. package/src/components/Button/Button.tsx +17 -1
  34. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  35. package/src/components/Calendar/Calendar.module.css +126 -0
  36. package/src/components/Calendar/Calendar.tsx +213 -0
  37. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  38. package/src/components/Calendar/dateUtils.ts +97 -0
  39. package/src/components/Card/Card.module.css +4 -2
  40. package/src/components/Checkbox/Checkbox.module.css +14 -2
  41. package/src/components/Checkbox/Checkbox.tsx +22 -12
  42. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  43. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  44. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  45. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  46. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  47. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  48. package/src/components/DatePicker/DatePicker.module.css +57 -0
  49. package/src/components/DatePicker/DatePicker.tsx +103 -0
  50. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  51. package/src/components/Dialog/Dialog.module.css +77 -50
  52. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  53. package/src/components/Dialog/Dialog.tsx +86 -22
  54. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  55. package/src/components/DragHandleRow/DragHandleRow.module.css +9 -3
  56. package/src/components/Drawer/Drawer.module.css +61 -0
  57. package/src/components/Drawer/Drawer.tsx +117 -13
  58. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  59. package/src/components/Icon/Icon.module.css +5 -0
  60. package/src/components/Icon/Icon.stories.tsx +4 -3
  61. package/src/components/Icon/Icon.tsx +1 -1
  62. package/src/components/Icon/icons/calendar.tsx +23 -0
  63. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  64. package/src/components/Icon/icons/index.ts +8 -4
  65. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  66. package/src/components/Menu/Menu.module.css +82 -0
  67. package/src/components/Menu/Menu.tsx +137 -0
  68. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  69. package/src/components/NavTabs/NavTabs.module.css +4 -2
  70. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  71. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  72. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  73. package/src/components/Popover/Popover.module.css +31 -1
  74. package/src/components/Popover/Popover.stories.tsx +126 -1
  75. package/src/components/Popover/Popover.tsx +140 -7
  76. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  77. package/src/components/Sheet/Sheet.module.css +55 -0
  78. package/src/components/Sheet/Sheet.tsx +151 -0
  79. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  80. package/src/components/SplitPane/SplitPane.module.css +85 -0
  81. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  82. package/src/components/SplitPane/SplitPane.tsx +149 -0
  83. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  84. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  85. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  86. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  87. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  88. package/src/components/Tabs/Tabs.module.css +4 -2
  89. package/src/components/TagInput/TagInput.module.css +141 -0
  90. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  91. package/src/components/TagInput/TagInput.tsx +174 -0
  92. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  93. package/src/components/Toast/Toast.module.css +4 -2
  94. package/src/components/Tooltip/Tooltip.module.css +6 -1
  95. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  96. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  97. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  98. package/src/hooks/index.ts +4 -0
  99. package/src/hooks/useDoubleTap.ts +91 -0
  100. package/src/hooks/useIsMobile.ts +33 -0
  101. package/src/hooks/useLongPress.ts +185 -0
  102. package/src/index.ts +30 -1
  103. package/src/motion.ts +78 -0
  104. package/src/stories/DesignSystemOverview.stories.tsx +292 -4
  105. package/src/stories/InteractionHooks.stories.tsx +388 -0
  106. package/src/stories/TokenGallery.stories.tsx +85 -1
  107. package/src/tokens/primitives.css +19 -0
  108. package/src/tokens/semantic.css +13 -0
  109. package/src/components/Icon/icons/terminal.tsx +0 -19
@@ -0,0 +1,141 @@
1
+ .wrapper {
2
+ display: flex;
3
+ width: 100%;
4
+ flex-direction: column;
5
+ gap: var(--sv-space-1);
6
+ }
7
+
8
+ .control {
9
+ display: flex;
10
+ min-height: 40px;
11
+ width: 100%;
12
+ align-items: center;
13
+ gap: var(--sv-space-1);
14
+ flex-wrap: wrap;
15
+ padding: 5px var(--sv-space-2);
16
+ color: var(--sv-color-text-primary);
17
+ background: var(--sv-color-surface);
18
+ border: 1px solid var(--sv-color-border-strong);
19
+ border-radius: var(--sv-radius-md);
20
+ transition:
21
+ border-color 0.15s ease,
22
+ box-shadow 0.15s ease;
23
+ }
24
+
25
+ .control:focus-within {
26
+ border-color: var(--sv-color-accent);
27
+ box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
28
+ }
29
+
30
+ .invalid {
31
+ border-color: var(--sv-color-error-border);
32
+ }
33
+
34
+ .invalid:focus-within {
35
+ border-color: var(--sv-color-error-border);
36
+ box-shadow: 0 0 0 2px var(--sv-color-error-border);
37
+ }
38
+
39
+ .disabled {
40
+ opacity: 0.5;
41
+ cursor: not-allowed;
42
+ }
43
+
44
+ .tag {
45
+ display: inline-flex;
46
+ max-width: 100%;
47
+ min-height: 28px;
48
+ align-items: center;
49
+ gap: var(--sv-space-1);
50
+ padding-inline: var(--sv-space-2) 2px;
51
+ color: var(--sv-color-text-primary);
52
+ background: var(--sv-color-surface-sunken);
53
+ border: 1px solid var(--sv-color-border);
54
+ border-radius: var(--sv-radius-full);
55
+ font-family: var(--sv-font-family);
56
+ font-size: var(--sv-font-size-xs);
57
+ font-weight: var(--sv-font-weight-medium);
58
+ }
59
+
60
+ .tagLabel {
61
+ min-width: 0;
62
+ overflow: hidden;
63
+ text-overflow: ellipsis;
64
+ white-space: nowrap;
65
+ }
66
+
67
+ .removeButton {
68
+ display: inline-flex;
69
+ width: 22px;
70
+ height: 22px;
71
+ align-items: center;
72
+ justify-content: center;
73
+ padding: 0;
74
+ color: var(--sv-color-text-muted);
75
+ background: transparent;
76
+ border: 0;
77
+ border-radius: var(--sv-radius-full);
78
+ cursor: pointer;
79
+ font: inherit;
80
+ line-height: 1;
81
+ }
82
+
83
+ @media (hover: hover) {
84
+ .removeButton:hover:not(:disabled) {
85
+ color: var(--sv-color-text-primary);
86
+ background: var(--sv-color-surface);
87
+ }
88
+ }
89
+
90
+ .removeButton:focus-visible {
91
+ outline: 2px solid var(--sv-color-focus-ring);
92
+ outline-offset: 1px;
93
+ }
94
+
95
+ .removeButton:disabled {
96
+ cursor: not-allowed;
97
+ }
98
+
99
+ .input {
100
+ min-width: 120px;
101
+ flex: 1 1 140px;
102
+ height: 28px;
103
+ padding: 0 var(--sv-space-1);
104
+ color: var(--sv-color-text-primary);
105
+ background: transparent;
106
+ border: 0;
107
+ outline: 0;
108
+ font-family: var(--sv-font-family);
109
+ font-size: var(--sv-font-size-sm);
110
+ line-height: 1.5;
111
+ }
112
+
113
+ .input::placeholder {
114
+ color: var(--sv-color-text-muted);
115
+ }
116
+
117
+ .input:disabled {
118
+ cursor: not-allowed;
119
+ }
120
+
121
+ .hint,
122
+ .error {
123
+ margin: 0;
124
+ font-family: var(--sv-font-family);
125
+ font-size: var(--sv-font-size-caption);
126
+ line-height: 1.4;
127
+ }
128
+
129
+ .hint {
130
+ color: var(--sv-color-text-muted);
131
+ }
132
+
133
+ .error {
134
+ color: var(--sv-color-error-text);
135
+ }
136
+
137
+ @media (pointer: coarse) {
138
+ .input {
139
+ font-size: var(--sv-font-size-md);
140
+ }
141
+ }
@@ -0,0 +1,109 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { TagInput } from './TagInput';
4
+ import { FormField } from '../FormField/FormField';
5
+
6
+ const meta = {
7
+ title: 'Components/TagInput',
8
+ component: TagInput,
9
+ parameters: {
10
+ layout: 'padded',
11
+ docs: {
12
+ description: {
13
+ component:
14
+ 'Controlled multi-value input for frontmatter tags, labels, and lightweight taxonomies.',
15
+ },
16
+ },
17
+ },
18
+ args: {
19
+ value: ['writing', 'launch'],
20
+ onChange: () => {},
21
+ },
22
+ } satisfies Meta<typeof TagInput>;
23
+
24
+ export default meta;
25
+ type Story = StoryObj<typeof meta>;
26
+
27
+ function ControlledTagInput({
28
+ initial = ['writing', 'launch'],
29
+ disabled = false,
30
+ error,
31
+ }: {
32
+ initial?: string[];
33
+ disabled?: boolean;
34
+ error?: string;
35
+ }) {
36
+ const [tags, setTags] = useState(initial);
37
+ return (
38
+ <TagInput
39
+ value={tags}
40
+ onChange={setTags}
41
+ placeholder="Add tag"
42
+ disabled={disabled}
43
+ error={error}
44
+ aria-label="Tags"
45
+ />
46
+ );
47
+ }
48
+
49
+ export const Default: Story = {
50
+ render: () => <ControlledTagInput />,
51
+ };
52
+
53
+ export const WithFormField: Story = {
54
+ render: () => {
55
+ const [tags, setTags] = useState(['plainwrite', 'frontmatter']);
56
+ return (
57
+ <FormField label="Tags" hint="Press Enter or comma to add a tag." id="story-tags">
58
+ {(field) => (
59
+ <TagInput
60
+ {...field}
61
+ value={tags}
62
+ onChange={setTags}
63
+ validateTag={(tag) =>
64
+ tag.length > 24 ? 'Tags must be 24 characters or fewer.' : undefined
65
+ }
66
+ />
67
+ )}
68
+ </FormField>
69
+ );
70
+ },
71
+ };
72
+
73
+ export const Error: Story = {
74
+ render: () => <ControlledTagInput error="At least one public tag is required." />,
75
+ };
76
+
77
+ export const Disabled: Story = {
78
+ render: () => <ControlledTagInput disabled />,
79
+ };
80
+
81
+ export const Keyboard: Story = {
82
+ render: () => {
83
+ const [tags, setTags] = useState<string[]>([]);
84
+ return (
85
+ <TagInput
86
+ value={tags}
87
+ onChange={setTags}
88
+ hint="Enter adds; comma separates; Backspace removes the last chip when the input is empty."
89
+ placeholder="Type and press Enter"
90
+ aria-label="Keyboard tag input"
91
+ />
92
+ );
93
+ },
94
+ };
95
+
96
+ export const LongContent: Story = {
97
+ render: () => (
98
+ <ControlledTagInput
99
+ initial={['editorial-calendar', 'launch', 'very-long-tag-name-that-still-clips', 'published']}
100
+ />
101
+ ),
102
+ };
103
+
104
+ export const Mobile: Story = {
105
+ parameters: {
106
+ viewport: { defaultViewport: 'mobile1' },
107
+ },
108
+ render: () => <ControlledTagInput initial={['mobile', 'touch', 'frontmatter']} />,
109
+ };
@@ -0,0 +1,174 @@
1
+ 'use client';
2
+
3
+ import type { KeyboardEvent, ClipboardEvent } from 'react';
4
+ import { useMemo, useState } from 'react';
5
+ import styles from './TagInput.module.css';
6
+
7
+ export interface TagInputProps {
8
+ value: string[];
9
+ onChange: (value: string[]) => void;
10
+ id?: string;
11
+ name?: string;
12
+ placeholder?: string;
13
+ disabled?: boolean;
14
+ required?: boolean;
15
+ error?: string;
16
+ hint?: string;
17
+ validateTag?: (tag: string, currentTags: string[]) => string | undefined;
18
+ separators?: RegExp;
19
+ className?: string;
20
+ 'aria-describedby'?: string;
21
+ 'aria-invalid'?: boolean;
22
+ 'aria-label'?: string;
23
+ }
24
+
25
+ const DEFAULT_SEPARATORS = /[\n,]+/;
26
+
27
+ const splitTags = (value: string, separators: RegExp) =>
28
+ value
29
+ .split(separators)
30
+ .map((tag) => tag.trim())
31
+ .filter(Boolean);
32
+
33
+ /**
34
+ * TagInput — controlled multi-value input for frontmatter tags, labels, and
35
+ * lightweight taxonomies. Use inside FormField just like a native input.
36
+ */
37
+ export function TagInput({
38
+ value,
39
+ onChange,
40
+ id,
41
+ name,
42
+ placeholder = 'Add tag',
43
+ disabled = false,
44
+ required = false,
45
+ error,
46
+ hint,
47
+ validateTag,
48
+ separators = DEFAULT_SEPARATORS,
49
+ className,
50
+ 'aria-describedby': ariaDescribedBy,
51
+ 'aria-invalid': ariaInvalid,
52
+ 'aria-label': ariaLabel,
53
+ }: TagInputProps) {
54
+ const [draft, setDraft] = useState('');
55
+ const [localError, setLocalError] = useState<string | undefined>();
56
+ const message = error ?? localError ?? hint;
57
+ const messageId = message && id ? `${id}-tag-message` : undefined;
58
+ const describedBy = [ariaDescribedBy, messageId].filter(Boolean).join(' ') || undefined;
59
+ const normalized = useMemo(() => new Set(value.map((tag) => tag.toLocaleLowerCase())), [value]);
60
+
61
+ const addTags = (tags: string[]) => {
62
+ const next = [...value];
63
+ const nextNormalized = new Set(normalized);
64
+
65
+ for (const tag of tags) {
66
+ const normalizedTag = tag.toLocaleLowerCase();
67
+ const duplicate = nextNormalized.has(normalizedTag);
68
+ const validationMessage = duplicate
69
+ ? 'Tag already exists.'
70
+ : (validateTag?.(tag, next) ?? undefined);
71
+
72
+ if (validationMessage) {
73
+ setLocalError(validationMessage);
74
+ return;
75
+ }
76
+
77
+ next.push(tag);
78
+ nextNormalized.add(normalizedTag);
79
+ }
80
+
81
+ setLocalError(undefined);
82
+ setDraft('');
83
+ onChange(next);
84
+ };
85
+
86
+ const commitDraft = () => {
87
+ const tags = splitTags(draft, separators);
88
+ if (tags.length === 0) {
89
+ return;
90
+ }
91
+ addTags(tags);
92
+ };
93
+
94
+ const removeTag = (index: number) => {
95
+ setLocalError(undefined);
96
+ onChange(value.filter((_, tagIndex) => tagIndex !== index));
97
+ };
98
+
99
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
100
+ if (event.key === 'Enter' || event.key === ',') {
101
+ event.preventDefault();
102
+ commitDraft();
103
+ }
104
+
105
+ if (event.key === 'Backspace' && draft.length === 0 && value.length > 0) {
106
+ event.preventDefault();
107
+ removeTag(value.length - 1);
108
+ }
109
+ };
110
+
111
+ const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
112
+ const text = event.clipboardData.getData('text');
113
+ const tags = splitTags(text, separators);
114
+ if (tags.length <= 1) {
115
+ return;
116
+ }
117
+ event.preventDefault();
118
+ addTags(tags);
119
+ };
120
+
121
+ return (
122
+ <div className={[styles.wrapper, className].filter(Boolean).join(' ')}>
123
+ <div
124
+ className={[
125
+ styles.control,
126
+ disabled ? styles.disabled : undefined,
127
+ error || localError || ariaInvalid ? styles.invalid : undefined,
128
+ ]
129
+ .filter(Boolean)
130
+ .join(' ')}
131
+ >
132
+ {value.map((tag, index) => (
133
+ <span key={`${tag}-${index}`} className={styles.tag}>
134
+ <span className={styles.tagLabel}>{tag}</span>
135
+ <button
136
+ type="button"
137
+ className={styles.removeButton}
138
+ onClick={() => removeTag(index)}
139
+ disabled={disabled}
140
+ aria-label={`Remove ${tag}`}
141
+ >
142
+ ×
143
+ </button>
144
+ </span>
145
+ ))}
146
+ <input
147
+ id={id}
148
+ name={name}
149
+ className={styles.input}
150
+ value={draft}
151
+ placeholder={value.length === 0 ? placeholder : undefined}
152
+ disabled={disabled}
153
+ required={required && value.length === 0}
154
+ aria-describedby={describedBy}
155
+ aria-invalid={ariaInvalid ?? (Boolean(error || localError) || undefined)}
156
+ aria-label={ariaLabel}
157
+ onChange={(event) => setDraft(event.currentTarget.value)}
158
+ onKeyDown={handleKeyDown}
159
+ onBlur={commitDraft}
160
+ onPaste={handlePaste}
161
+ />
162
+ </div>
163
+ {message && (
164
+ <p
165
+ id={messageId}
166
+ className={error || localError ? styles.error : styles.hint}
167
+ role={error || localError ? 'alert' : undefined}
168
+ >
169
+ {message}
170
+ </p>
171
+ )}
172
+ </div>
173
+ );
174
+ }
@@ -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
+ });
@@ -96,8 +96,10 @@
96
96
  font-size: var(--sv-font-size-md);
97
97
  }
98
98
 
99
- .close:hover {
100
- color: var(--sv-color-text-primary);
99
+ @media (hover: hover) {
100
+ .close:hover {
101
+ color: var(--sv-color-text-primary);
102
+ }
101
103
  }
102
104
 
103
105
  @keyframes toastIn {
@@ -20,7 +20,12 @@
20
20
  transition: opacity 0.1s ease;
21
21
  }
22
22
 
23
- .wrapper:hover .tip,
23
+ @media (hover: hover) {
24
+ .wrapper:hover .tip {
25
+ opacity: 1;
26
+ }
27
+ }
28
+
24
29
  .wrapper:focus-within .tip {
25
30
  opacity: 1;
26
31
  }
@@ -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
+ });