@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,59 @@
|
|
|
1
|
+
.badge {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
max-width: 100%;
|
|
5
|
+
min-height: 24px;
|
|
6
|
+
gap: var(--sv-space-1);
|
|
7
|
+
padding: 2px var(--sv-space-2);
|
|
8
|
+
border: 1px solid var(--sv-color-border);
|
|
9
|
+
border-radius: var(--sv-radius-full);
|
|
10
|
+
font-family: var(--sv-font-family);
|
|
11
|
+
font-size: var(--sv-font-size-xs);
|
|
12
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
13
|
+
line-height: 1.2;
|
|
14
|
+
white-space: nowrap;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.dot {
|
|
18
|
+
width: 7px;
|
|
19
|
+
height: 7px;
|
|
20
|
+
flex: 0 0 auto;
|
|
21
|
+
border-radius: var(--sv-radius-full);
|
|
22
|
+
background: currentColor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.label {
|
|
26
|
+
min-width: 0;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
text-overflow: ellipsis;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.neutral {
|
|
32
|
+
color: var(--sv-color-text-muted);
|
|
33
|
+
background: var(--sv-color-surface-sunken);
|
|
34
|
+
border-color: var(--sv-color-border);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.info {
|
|
38
|
+
color: var(--sv-color-info-text);
|
|
39
|
+
background: var(--sv-color-info-surface);
|
|
40
|
+
border-color: var(--sv-color-info-border);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.success {
|
|
44
|
+
color: var(--sv-color-success-text);
|
|
45
|
+
background: var(--sv-color-success-surface);
|
|
46
|
+
border-color: var(--sv-color-success-border);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.warning {
|
|
50
|
+
color: var(--sv-color-warning-text);
|
|
51
|
+
background: var(--sv-color-warning-surface);
|
|
52
|
+
border-color: var(--sv-color-warning-border);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.error {
|
|
56
|
+
color: var(--sv-color-error-text);
|
|
57
|
+
background: var(--sv-color-error-surface);
|
|
58
|
+
border-color: var(--sv-color-error-border);
|
|
59
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { StatusBadge } from './StatusBadge';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/StatusBadge',
|
|
6
|
+
component: StatusBadge,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'centered',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
'Compact inline status indicator for draft, sync, conflict, and delete-pending editor workflows.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: {
|
|
17
|
+
status: 'draft',
|
|
18
|
+
},
|
|
19
|
+
} satisfies Meta<typeof StatusBadge>;
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sv-space-2)' }}>
|
|
27
|
+
<StatusBadge status="unmodified" />
|
|
28
|
+
<StatusBadge status="draft" />
|
|
29
|
+
<StatusBadge status="committed" />
|
|
30
|
+
<StatusBadge status="synced" />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const ErrorStates: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sv-space-2)' }}>
|
|
38
|
+
<StatusBadge status="conflict" />
|
|
39
|
+
<StatusBadge status="pending-delete" />
|
|
40
|
+
<StatusBadge status="warning" />
|
|
41
|
+
<StatusBadge status="error" />
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const AccessibleLabel: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<StatusBadge status="draft" aria-label="Draft with unpublished changes">
|
|
49
|
+
D
|
|
50
|
+
</StatusBadge>
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const LongContent: Story = {
|
|
55
|
+
render: () => (
|
|
56
|
+
<div style={{ width: 180 }}>
|
|
57
|
+
<StatusBadge status="warning">Preview requires frontmatter migration</StatusBadge>
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const MobileWrap: Story = {
|
|
63
|
+
parameters: {
|
|
64
|
+
viewport: { defaultViewport: 'mobile1' },
|
|
65
|
+
},
|
|
66
|
+
render: () => (
|
|
67
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sv-space-2)', maxWidth: 320 }}>
|
|
68
|
+
<StatusBadge status="draft" />
|
|
69
|
+
<StatusBadge status="synced" />
|
|
70
|
+
<StatusBadge status="conflict" />
|
|
71
|
+
<StatusBadge status="pending-delete" />
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import styles from './StatusBadge.module.css';
|
|
3
|
+
|
|
4
|
+
export type StatusBadgeStatus =
|
|
5
|
+
| 'unmodified'
|
|
6
|
+
| 'draft'
|
|
7
|
+
| 'committed'
|
|
8
|
+
| 'conflict'
|
|
9
|
+
| 'pending-delete'
|
|
10
|
+
| 'synced'
|
|
11
|
+
| 'warning'
|
|
12
|
+
| 'error';
|
|
13
|
+
|
|
14
|
+
export interface StatusBadgeProps {
|
|
15
|
+
status: StatusBadgeStatus;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
/** Accessible label when the visible text is abbreviated or contextual. */
|
|
18
|
+
'aria-label'?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const STATUS_CLASS: Record<StatusBadgeStatus, string> = {
|
|
23
|
+
unmodified: styles.neutral as string,
|
|
24
|
+
draft: styles.info as string,
|
|
25
|
+
committed: styles.success as string,
|
|
26
|
+
conflict: styles.error as string,
|
|
27
|
+
'pending-delete': styles.warning as string,
|
|
28
|
+
synced: styles.success as string,
|
|
29
|
+
warning: styles.warning as string,
|
|
30
|
+
error: styles.error as string,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const STATUS_LABEL: Record<StatusBadgeStatus, string> = {
|
|
34
|
+
unmodified: 'Unmodified',
|
|
35
|
+
draft: 'Draft',
|
|
36
|
+
committed: 'Committed',
|
|
37
|
+
conflict: 'Conflict',
|
|
38
|
+
'pending-delete': 'Pending delete',
|
|
39
|
+
synced: 'Synced',
|
|
40
|
+
warning: 'Warning',
|
|
41
|
+
error: 'Error',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* StatusBadge — compact inline status indicator for editor lists, draft
|
|
46
|
+
* workflows, and sync states.
|
|
47
|
+
*/
|
|
48
|
+
export function StatusBadge({
|
|
49
|
+
status,
|
|
50
|
+
children,
|
|
51
|
+
className,
|
|
52
|
+
'aria-label': ariaLabel,
|
|
53
|
+
}: StatusBadgeProps) {
|
|
54
|
+
const label = children ?? STATUS_LABEL[status];
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<span
|
|
58
|
+
className={[styles.badge, STATUS_CLASS[status], className].filter(Boolean).join(' ')}
|
|
59
|
+
aria-label={ariaLabel}
|
|
60
|
+
>
|
|
61
|
+
<span className={styles.dot} aria-hidden="true" />
|
|
62
|
+
<span className={styles.label}>{label}</span>
|
|
63
|
+
</span>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|