@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.
Files changed (130) 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 +109 -0
  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 +73 -46
  9. package/dist/DragHandleRow.module.css +57 -0
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/FormField.module.css +5 -0
  12. package/dist/Icon.module.css +5 -0
  13. package/dist/Input.module.css +10 -0
  14. package/dist/Menu.module.css +82 -0
  15. package/dist/NavTabs.module.css +4 -2
  16. package/dist/OverlayHeader.module.css +98 -0
  17. package/dist/Popover.module.css +31 -1
  18. package/dist/SegmentedControl.module.css +4 -2
  19. package/dist/Select.module.css +9 -0
  20. package/dist/Sheet.module.css +55 -0
  21. package/dist/SplitPane.module.css +85 -0
  22. package/dist/StatusBadge.module.css +59 -0
  23. package/dist/SystemBanner.module.css +4 -2
  24. package/dist/Tabs.module.css +4 -2
  25. package/dist/TagInput.module.css +141 -0
  26. package/dist/Textarea.module.css +39 -0
  27. package/dist/Toast.module.css +7 -5
  28. package/dist/Toggle.module.css +1 -1
  29. package/dist/Tooltip.module.css +6 -1
  30. package/dist/index.d.ts +549 -20
  31. package/dist/index.js +1496 -193
  32. package/dist/tokens/primitives.css +19 -0
  33. package/dist/tokens/semantic.css +19 -0
  34. package/package.json +1 -1
  35. package/src/__tests__/motion.test.tsx +105 -0
  36. package/src/components/Button/Button.module.css +75 -8
  37. package/src/components/Button/Button.stories.tsx +6 -0
  38. package/src/components/Button/Button.tsx +17 -1
  39. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  40. package/src/components/Calendar/Calendar.module.css +126 -0
  41. package/src/components/Calendar/Calendar.tsx +213 -0
  42. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  43. package/src/components/Calendar/dateUtils.ts +97 -0
  44. package/src/components/Card/Card.module.css +4 -2
  45. package/src/components/Checkbox/Checkbox.module.css +109 -0
  46. package/src/components/Checkbox/Checkbox.tsx +78 -0
  47. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  48. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  49. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  50. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  51. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  52. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  53. package/src/components/DatePicker/DatePicker.module.css +57 -0
  54. package/src/components/DatePicker/DatePicker.tsx +103 -0
  55. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  56. package/src/components/Dialog/Dialog.module.css +73 -46
  57. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  58. package/src/components/Dialog/Dialog.tsx +86 -22
  59. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  60. package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
  61. package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
  62. package/src/components/Drawer/Drawer.module.css +61 -0
  63. package/src/components/Drawer/Drawer.tsx +117 -13
  64. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  65. package/src/components/FormField/FormField.module.css +5 -0
  66. package/src/components/FormField/FormField.tsx +36 -11
  67. package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
  68. package/src/components/Icon/Icon.module.css +5 -0
  69. package/src/components/Icon/Icon.stories.tsx +4 -3
  70. package/src/components/Icon/Icon.tsx +1 -1
  71. package/src/components/Icon/icons/calendar.tsx +23 -0
  72. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  73. package/src/components/Icon/icons/index.ts +8 -4
  74. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  75. package/src/components/Input/Input.module.css +10 -0
  76. package/src/components/Menu/Menu.module.css +82 -0
  77. package/src/components/Menu/Menu.tsx +137 -0
  78. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  79. package/src/components/NavTabs/NavTabs.module.css +4 -2
  80. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  81. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  82. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  83. package/src/components/Popover/Popover.module.css +31 -1
  84. package/src/components/Popover/Popover.stories.tsx +126 -1
  85. package/src/components/Popover/Popover.tsx +140 -7
  86. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  87. package/src/components/Select/Select.module.css +9 -0
  88. package/src/components/Select/Select.tsx +10 -2
  89. package/src/components/Sheet/Sheet.module.css +55 -0
  90. package/src/components/Sheet/Sheet.tsx +151 -0
  91. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  92. package/src/components/SplitPane/SplitPane.module.css +85 -0
  93. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  94. package/src/components/SplitPane/SplitPane.tsx +149 -0
  95. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  96. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  97. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  98. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  99. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  100. package/src/components/Tabs/Tabs.module.css +4 -2
  101. package/src/components/TagInput/TagInput.module.css +141 -0
  102. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  103. package/src/components/TagInput/TagInput.tsx +174 -0
  104. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  105. package/src/components/Textarea/Textarea.module.css +39 -0
  106. package/src/components/Textarea/Textarea.tsx +15 -0
  107. package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
  108. package/src/components/Toast/Toast.module.css +7 -5
  109. package/src/components/Toggle/Toggle.module.css +1 -1
  110. package/src/components/Tooltip/Tooltip.module.css +6 -1
  111. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  112. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  113. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  114. package/src/hooks/index.ts +4 -0
  115. package/src/hooks/useDoubleTap.ts +91 -0
  116. package/src/hooks/useIsMobile.ts +33 -0
  117. package/src/hooks/useLongPress.ts +185 -0
  118. package/src/index.ts +37 -2
  119. package/src/motion.ts +78 -0
  120. package/src/stories/Checkbox.stories.tsx +45 -0
  121. package/src/stories/DesignSystemOverview.stories.tsx +354 -8
  122. package/src/stories/DragHandleRow.stories.tsx +41 -0
  123. package/src/stories/FormField.stories.tsx +8 -8
  124. package/src/stories/InteractionHooks.stories.tsx +388 -0
  125. package/src/stories/MobilePatterns.stories.tsx +4 -4
  126. package/src/stories/Textarea.stories.tsx +51 -0
  127. package/src/stories/TokenGallery.stories.tsx +87 -1
  128. package/src/tokens/primitives.css +19 -0
  129. package/src/tokens/semantic.css +19 -0
  130. 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
+ }
@@ -38,8 +38,10 @@
38
38
  transition: opacity 0.12s ease;
39
39
  }
40
40
 
41
- .dismiss:hover {
42
- opacity: 1;
41
+ @media (hover: hover) {
42
+ .dismiss:hover {
43
+ opacity: 1;
44
+ }
43
45
  }
44
46
 
45
47
  .dismiss:focus-visible {
@@ -44,6 +44,8 @@
44
44
  font-weight: var(--sv-font-weight-medium);
45
45
  }
46
46
 
47
- .inactive:hover {
48
- color: var(--sv-color-text-primary);
47
+ @media (hover: hover) {
48
+ .inactive:hover {
49
+ color: var(--sv-color-text-primary);
50
+ }
49
51
  }
@@ -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
+ }