@sovereignfs/ui 0.23.0 → 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 +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 +73 -46
- 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 +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 +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,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
|
+
});
|
|
@@ -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
|
+
});
|