@duro-app/ui 0.25.0 → 0.27.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/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -1
- package/dist/components/Field/Field.d.ts.map +1 -1
- package/dist/components/Field/Field.stories.d.ts.map +1 -1
- package/dist/components/Form/Form.d.ts.map +1 -1
- package/dist/components/ScrollArea/ScrollArea.d.ts.map +1 -1
- package/dist/components/ScrollArea/ScrollAreaContext.d.ts +1 -0
- package/dist/components/ScrollArea/ScrollAreaContext.d.ts.map +1 -1
- package/dist/components/ScrollArea/useScrollAreaRoot.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Tag/Tag.d.ts +18 -0
- package/dist/components/Tag/Tag.d.ts.map +1 -0
- package/dist/components/Tag/Tag.meta.d.ts +3 -0
- package/dist/components/Tag/Tag.meta.d.ts.map +1 -0
- package/dist/components/Tag/Tag.stories.d.ts +11 -0
- package/dist/components/Tag/Tag.stories.d.ts.map +1 -0
- package/dist/components/Tag/styles.css.d.ts +82 -0
- package/dist/components/Tag/styles.css.d.ts.map +1 -0
- package/dist/components/TagGroup/TagGroup.d.ts +30 -0
- package/dist/components/TagGroup/TagGroup.d.ts.map +1 -0
- package/dist/components/TagGroup/TagGroup.meta.d.ts +3 -0
- package/dist/components/TagGroup/TagGroup.meta.d.ts.map +1 -0
- package/dist/components/TagGroup/TagGroup.stories.d.ts +11 -0
- package/dist/components/TagGroup/TagGroup.stories.d.ts.map +1 -0
- package/dist/components/TagGroup/TagGroupContext.d.ts +17 -0
- package/dist/components/TagGroup/TagGroupContext.d.ts.map +1 -0
- package/dist/components/TagGroup/styles.css.d.ts +61 -0
- package/dist/components/TagGroup/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2479 -2128
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +0 -1
- package/src/components/Combobox/Combobox.meta.ts +8 -2
- package/src/components/Combobox/Combobox.stories.tsx +52 -14
- package/src/components/Combobox/Combobox.tsx +3 -15
- package/src/components/Field/Field.stories.tsx +3 -1
- package/src/components/Field/Field.tsx +1 -4
- package/src/components/Field/styles.css.ts +1 -1
- package/src/components/Form/Form.tsx +1 -5
- package/src/components/ScrollArea/ScrollArea.tsx +27 -3
- package/src/components/ScrollArea/ScrollAreaContext.ts +1 -0
- package/src/components/ScrollArea/useScrollAreaRoot.ts +3 -1
- package/src/components/Table/Table.stories.tsx +66 -11
- package/src/components/Tag/Tag.meta.ts +22 -0
- package/src/components/Tag/Tag.stories.tsx +92 -0
- package/src/components/Tag/Tag.tsx +141 -0
- package/src/components/Tag/styles.css.ts +104 -0
- package/src/components/TagGroup/TagGroup.meta.ts +27 -0
- package/src/components/TagGroup/TagGroup.stories.tsx +193 -0
- package/src/components/TagGroup/TagGroup.tsx +302 -0
- package/src/components/TagGroup/TagGroupContext.ts +22 -0
- package/src/components/TagGroup/styles.css.ts +83 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
+
|
|
6
|
+
export const styles = css.create({
|
|
7
|
+
base: {
|
|
8
|
+
display: 'inline-flex',
|
|
9
|
+
alignItems: 'center',
|
|
10
|
+
gap: spacing.sm,
|
|
11
|
+
fontFamily: typography.fontFamily,
|
|
12
|
+
fontWeight: typography.fontWeightNormal,
|
|
13
|
+
lineHeight: 1,
|
|
14
|
+
borderRadius: 4,
|
|
15
|
+
whiteSpace: 'nowrap',
|
|
16
|
+
borderWidth: 1,
|
|
17
|
+
borderStyle: 'solid',
|
|
18
|
+
borderColor: colors.border,
|
|
19
|
+
outlineWidth: {
|
|
20
|
+
default: 0,
|
|
21
|
+
':focus-visible': 2,
|
|
22
|
+
},
|
|
23
|
+
outlineStyle: {
|
|
24
|
+
default: 'none',
|
|
25
|
+
':focus-visible': 'solid',
|
|
26
|
+
},
|
|
27
|
+
outlineColor: {
|
|
28
|
+
default: 'transparent',
|
|
29
|
+
':focus-visible': colors.accent,
|
|
30
|
+
},
|
|
31
|
+
outlineOffset: {
|
|
32
|
+
default: 0,
|
|
33
|
+
':focus-visible': 1,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
sizeMd: {
|
|
37
|
+
paddingTop: spacing.sm,
|
|
38
|
+
paddingBottom: spacing.sm,
|
|
39
|
+
paddingLeft: spacing.ms,
|
|
40
|
+
paddingRight: spacing.ms,
|
|
41
|
+
fontSize: typography.fontSizeSm,
|
|
42
|
+
},
|
|
43
|
+
sizeSm: {
|
|
44
|
+
paddingTop: spacing.xs,
|
|
45
|
+
paddingBottom: spacing.xs,
|
|
46
|
+
paddingLeft: spacing.sm,
|
|
47
|
+
paddingRight: spacing.sm,
|
|
48
|
+
fontSize: typography.fontSizeXs,
|
|
49
|
+
},
|
|
50
|
+
// Reduce right padding when removable to keep visual balance with the button
|
|
51
|
+
removableMd: {
|
|
52
|
+
paddingRight: spacing.sm,
|
|
53
|
+
},
|
|
54
|
+
removableSm: {
|
|
55
|
+
paddingRight: spacing.xs,
|
|
56
|
+
},
|
|
57
|
+
default: {
|
|
58
|
+
backgroundColor: {
|
|
59
|
+
default: colors.bg,
|
|
60
|
+
':hover': colors.bgCardHover,
|
|
61
|
+
},
|
|
62
|
+
color: colors.text,
|
|
63
|
+
},
|
|
64
|
+
success: {
|
|
65
|
+
backgroundColor: colors.successBg,
|
|
66
|
+
color: colors.successText,
|
|
67
|
+
},
|
|
68
|
+
warning: {
|
|
69
|
+
backgroundColor: colors.warningBg,
|
|
70
|
+
color: colors.warningText,
|
|
71
|
+
},
|
|
72
|
+
error: {
|
|
73
|
+
backgroundColor: colors.errorBg,
|
|
74
|
+
color: colors.errorText,
|
|
75
|
+
},
|
|
76
|
+
info: {
|
|
77
|
+
backgroundColor: colors.infoBg,
|
|
78
|
+
color: colors.infoText,
|
|
79
|
+
},
|
|
80
|
+
disabled: {
|
|
81
|
+
opacity: 0.5,
|
|
82
|
+
cursor: 'not-allowed',
|
|
83
|
+
},
|
|
84
|
+
removeButton: {
|
|
85
|
+
display: 'inline-flex',
|
|
86
|
+
alignItems: 'center',
|
|
87
|
+
justifyContent: 'center',
|
|
88
|
+
padding: 0,
|
|
89
|
+
borderWidth: 0,
|
|
90
|
+
backgroundColor: 'transparent',
|
|
91
|
+
color: 'inherit',
|
|
92
|
+
cursor: 'pointer',
|
|
93
|
+
borderRadius: radii.full,
|
|
94
|
+
opacity: {
|
|
95
|
+
default: 0.6,
|
|
96
|
+
':hover': 1,
|
|
97
|
+
},
|
|
98
|
+
outlineWidth: 0,
|
|
99
|
+
},
|
|
100
|
+
removeButtonDisabled: {
|
|
101
|
+
cursor: 'not-allowed',
|
|
102
|
+
opacity: 0.4,
|
|
103
|
+
},
|
|
104
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Compound component for managing a collection of tags. Supports editable mode with an inline input for adding tags via Enter/comma, removal via X button or Backspace, and paste support for bulk adding.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Email address input (add/remove multiple emails)',
|
|
8
|
+
'Editable tag collections (categories, labels)',
|
|
9
|
+
'Static display of tag groups with optional removal',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Single value selection — use Select or Combobox',
|
|
13
|
+
'Static non-interactive labels — use Badge with Cluster',
|
|
14
|
+
'Toggle-based selection — use ToggleGroup',
|
|
15
|
+
],
|
|
16
|
+
relatedTo: [
|
|
17
|
+
{component: 'Tag', relationship: 'Tag is the item inside TagGroup.List'},
|
|
18
|
+
{component: 'Badge', relationship: 'Static display alternative'},
|
|
19
|
+
{component: 'Field', relationship: 'Wrap in Field.Root for label + description + error'},
|
|
20
|
+
],
|
|
21
|
+
example: `<TagGroup.Root name="emails" value={emails} onValueChange={setEmails}>
|
|
22
|
+
<TagGroup.List aria-label="Emails">
|
|
23
|
+
{emails.map(e => <Tag key={e} value={e}>{e}</Tag>)}
|
|
24
|
+
</TagGroup.List>
|
|
25
|
+
<TagGroup.Input placeholder="Add email..." />
|
|
26
|
+
</TagGroup.Root>`,
|
|
27
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {useState} from 'react'
|
|
2
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
3
|
+
import {expect, userEvent, within} from 'storybook/test'
|
|
4
|
+
import {css, html} from 'react-strict-dom'
|
|
5
|
+
import {TagGroup} from './TagGroup'
|
|
6
|
+
import {Tag} from '../Tag/Tag'
|
|
7
|
+
import {Field} from '../Field/Field'
|
|
8
|
+
import {Stack} from '../Stack/Stack'
|
|
9
|
+
|
|
10
|
+
const meta: Meta = {
|
|
11
|
+
title: 'Components/TagGroup',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default meta
|
|
15
|
+
type Story = StoryObj
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Static display (no input)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export const StaticDisplay: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<TagGroup.Root>
|
|
24
|
+
<TagGroup.List aria-label="Technologies">
|
|
25
|
+
<Tag value="react">React</Tag>
|
|
26
|
+
<Tag value="ts">TypeScript</Tag>
|
|
27
|
+
<Tag value="node">Node.js</Tag>
|
|
28
|
+
</TagGroup.List>
|
|
29
|
+
</TagGroup.Root>
|
|
30
|
+
),
|
|
31
|
+
play: async ({canvas}) => {
|
|
32
|
+
await expect(canvas.getByText('React')).toBeInTheDocument()
|
|
33
|
+
await expect(canvas.getByText('TypeScript')).toBeInTheDocument()
|
|
34
|
+
await expect(canvas.getByText('Node.js')).toBeInTheDocument()
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Editable with input
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function EditableExample() {
|
|
43
|
+
const [tags, setTags] = useState(['react', 'typescript'])
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<TagGroup.Root value={tags} onValueChange={setTags}>
|
|
47
|
+
<TagGroup.Input placeholder="Add tag..." />
|
|
48
|
+
<TagGroup.List aria-label="Tags">
|
|
49
|
+
{tags.map((t) => (
|
|
50
|
+
<Tag key={t} value={t}>
|
|
51
|
+
{t}
|
|
52
|
+
</Tag>
|
|
53
|
+
))}
|
|
54
|
+
</TagGroup.List>
|
|
55
|
+
</TagGroup.Root>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const Editable: Story = {
|
|
60
|
+
render: () => <EditableExample />,
|
|
61
|
+
play: async ({canvas}) => {
|
|
62
|
+
await expect(canvas.getByText('react')).toBeInTheDocument()
|
|
63
|
+
await expect(canvas.getByText('typescript')).toBeInTheDocument()
|
|
64
|
+
// Input should be present
|
|
65
|
+
await expect(canvas.getByRole('textbox')).toBeInTheDocument()
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// With default value (uncontrolled)
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export const WithDefaultValue: Story = {
|
|
74
|
+
render: () => {
|
|
75
|
+
function Uncontrolled() {
|
|
76
|
+
const [tags, setTags] = useState(['one', 'two'])
|
|
77
|
+
return (
|
|
78
|
+
<TagGroup.Root defaultValue={['one', 'two']} onValueChange={setTags}>
|
|
79
|
+
<TagGroup.Input placeholder="Add..." />
|
|
80
|
+
<TagGroup.List aria-label="Items">
|
|
81
|
+
{tags.map((t) => (
|
|
82
|
+
<Tag key={t} value={t}>
|
|
83
|
+
{t}
|
|
84
|
+
</Tag>
|
|
85
|
+
))}
|
|
86
|
+
</TagGroup.List>
|
|
87
|
+
</TagGroup.Root>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
return <Uncontrolled />
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Email input with validation
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
function EmailExample() {
|
|
99
|
+
const [emails, setEmails] = useState<string[]>([])
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Stack gap="md">
|
|
103
|
+
<Field.Root required>
|
|
104
|
+
<Field.Label>Invite emails</Field.Label>
|
|
105
|
+
<Field.Description>Press Enter or comma to add each email</Field.Description>
|
|
106
|
+
<TagGroup.Root
|
|
107
|
+
name="emails"
|
|
108
|
+
value={emails}
|
|
109
|
+
onValueChange={setEmails}
|
|
110
|
+
onValidate={(v) => (v.includes('@') ? true : 'Invalid email')}
|
|
111
|
+
>
|
|
112
|
+
<TagGroup.Input placeholder="Add email..." />
|
|
113
|
+
<TagGroup.List aria-label="Emails">
|
|
114
|
+
{emails.map((e) => (
|
|
115
|
+
<Tag key={e} value={e}>
|
|
116
|
+
{e}
|
|
117
|
+
</Tag>
|
|
118
|
+
))}
|
|
119
|
+
</TagGroup.List>
|
|
120
|
+
</TagGroup.Root>
|
|
121
|
+
</Field.Root>
|
|
122
|
+
</Stack>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const EmailInput: Story = {
|
|
127
|
+
render: () => <EmailExample />,
|
|
128
|
+
play: async ({canvas}) => {
|
|
129
|
+
const input = canvas.getByRole('textbox')
|
|
130
|
+
await expect(input).toBeInTheDocument()
|
|
131
|
+
// Type an email and press Enter
|
|
132
|
+
await userEvent.type(input, 'alice@example.com{Enter}')
|
|
133
|
+
await expect(canvas.getByText('alice@example.com')).toBeInTheDocument()
|
|
134
|
+
// Type another
|
|
135
|
+
await userEvent.type(input, 'bob@test.com,')
|
|
136
|
+
await expect(canvas.getByText('bob@test.com')).toBeInTheDocument()
|
|
137
|
+
// Invalid email should not create a tag
|
|
138
|
+
await userEvent.type(input, 'notanemail{Enter}')
|
|
139
|
+
expect(canvas.queryByText('notanemail')).not.toBeInTheDocument()
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Disabled
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function DisabledExample() {
|
|
148
|
+
const tags = ['react', 'vue', 'svelte']
|
|
149
|
+
return (
|
|
150
|
+
<TagGroup.Root value={tags} disabled>
|
|
151
|
+
<TagGroup.Input placeholder="Disabled..." />
|
|
152
|
+
<TagGroup.List aria-label="Frameworks">
|
|
153
|
+
{tags.map((t) => (
|
|
154
|
+
<Tag key={t} value={t}>
|
|
155
|
+
{t}
|
|
156
|
+
</Tag>
|
|
157
|
+
))}
|
|
158
|
+
</TagGroup.List>
|
|
159
|
+
</TagGroup.Root>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const Disabled: Story = {
|
|
164
|
+
render: () => <DisabledExample />,
|
|
165
|
+
play: async ({canvas}) => {
|
|
166
|
+
await expect(canvas.getByRole('textbox')).toBeDisabled()
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Tag variants inside TagGroup
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export const WithVariants: Story = {
|
|
175
|
+
render: () => (
|
|
176
|
+
<TagGroup.Root>
|
|
177
|
+
<TagGroup.List aria-label="Status">
|
|
178
|
+
<Tag value="active" variant="success">
|
|
179
|
+
Active
|
|
180
|
+
</Tag>
|
|
181
|
+
<Tag value="pending" variant="warning">
|
|
182
|
+
Pending
|
|
183
|
+
</Tag>
|
|
184
|
+
<Tag value="expired" variant="error">
|
|
185
|
+
Expired
|
|
186
|
+
</Tag>
|
|
187
|
+
<Tag value="info" variant="info">
|
|
188
|
+
Updated
|
|
189
|
+
</Tag>
|
|
190
|
+
</TagGroup.List>
|
|
191
|
+
</TagGroup.Root>
|
|
192
|
+
),
|
|
193
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import {html} from 'react-strict-dom'
|
|
12
|
+
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
13
|
+
import {useFieldContext} from '../Field/FieldContext'
|
|
14
|
+
import {TagGroupContext, type TagGroupContextValue} from './TagGroupContext'
|
|
15
|
+
import {styles} from './styles.css'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Local context accessor (non-throwing, returns null)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function useTagGroupCtx() {
|
|
22
|
+
return useContext(TagGroupContext)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// TagGroup.Root
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface RootProps {
|
|
30
|
+
value?: string[]
|
|
31
|
+
defaultValue?: string[]
|
|
32
|
+
onValueChange?: (values: string[]) => void
|
|
33
|
+
/** Validate a value before adding. Return `true` if valid, or an error string. */
|
|
34
|
+
onValidate?: (value: string) => true | string
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
/** Form field name — renders hidden inputs for each tag value */
|
|
37
|
+
name?: string
|
|
38
|
+
'aria-label'?: string
|
|
39
|
+
children: ReactNode
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Root({
|
|
43
|
+
value: controlledValue,
|
|
44
|
+
defaultValue = [],
|
|
45
|
+
onValueChange,
|
|
46
|
+
onValidate,
|
|
47
|
+
disabled: disabledProp = false,
|
|
48
|
+
name,
|
|
49
|
+
'aria-label': ariaLabel,
|
|
50
|
+
children,
|
|
51
|
+
}: RootProps) {
|
|
52
|
+
const gridId = useId()
|
|
53
|
+
const fieldCtx = useFieldContext()
|
|
54
|
+
const [values, setValues] = useControllableValue(controlledValue, defaultValue, onValueChange)
|
|
55
|
+
const [focusedIndex, setFocusedIndex] = useState(-1)
|
|
56
|
+
const [editable, setEditable] = useState(false)
|
|
57
|
+
const [announcement, setAnnouncement] = useState('')
|
|
58
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
59
|
+
|
|
60
|
+
const disabled = disabledProp || (fieldCtx?.disabled ?? false)
|
|
61
|
+
const isInvalid = fieldCtx?.invalid ?? false
|
|
62
|
+
|
|
63
|
+
const addTag = useCallback(
|
|
64
|
+
(value: string) => {
|
|
65
|
+
const trimmed = value.trim()
|
|
66
|
+
if (!trimmed) return
|
|
67
|
+
if (values.includes(trimmed)) return
|
|
68
|
+
if (onValidate) {
|
|
69
|
+
const result = onValidate(trimmed)
|
|
70
|
+
if (result !== true) return
|
|
71
|
+
}
|
|
72
|
+
setValues([...values, trimmed])
|
|
73
|
+
setAnnouncement(`Added ${trimmed}`)
|
|
74
|
+
},
|
|
75
|
+
[values, setValues, onValidate],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const removeTag = useCallback(
|
|
79
|
+
(value: string) => {
|
|
80
|
+
const idx = values.indexOf(value)
|
|
81
|
+
if (idx === -1) return
|
|
82
|
+
const next = values.filter((v) => v !== value)
|
|
83
|
+
setValues(next)
|
|
84
|
+
setAnnouncement(`Removed ${value}`)
|
|
85
|
+
|
|
86
|
+
// Focus management: move to next tag, or previous, or input
|
|
87
|
+
if (next.length === 0) {
|
|
88
|
+
inputRef.current?.focus()
|
|
89
|
+
setFocusedIndex(-1)
|
|
90
|
+
} else if (idx >= next.length) {
|
|
91
|
+
setFocusedIndex(next.length - 1)
|
|
92
|
+
} else {
|
|
93
|
+
setFocusedIndex(idx)
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
[values, setValues],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// Focus the tag at focusedIndex when it changes
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (focusedIndex >= 0 && focusedIndex < values.length) {
|
|
102
|
+
const container = document.getElementById(gridId)
|
|
103
|
+
if (!container) return
|
|
104
|
+
const rows = container.querySelectorAll('[role="row"]')
|
|
105
|
+
const target = rows[focusedIndex] as HTMLElement | undefined
|
|
106
|
+
target?.focus()
|
|
107
|
+
}
|
|
108
|
+
}, [focusedIndex, gridId, values.length])
|
|
109
|
+
|
|
110
|
+
const ctx = useMemo<TagGroupContextValue>(
|
|
111
|
+
() => ({
|
|
112
|
+
values,
|
|
113
|
+
addTag,
|
|
114
|
+
removeTag,
|
|
115
|
+
editable,
|
|
116
|
+
setEditable,
|
|
117
|
+
disabled,
|
|
118
|
+
validate: onValidate,
|
|
119
|
+
focusedIndex,
|
|
120
|
+
setFocusedIndex,
|
|
121
|
+
inputRef,
|
|
122
|
+
gridId,
|
|
123
|
+
name,
|
|
124
|
+
}),
|
|
125
|
+
[values, addTag, removeTag, editable, disabled, onValidate, focusedIndex, gridId, name],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<TagGroupContext.Provider value={ctx}>
|
|
130
|
+
<html.div style={styles.root} aria-label={ariaLabel}>
|
|
131
|
+
<html.div
|
|
132
|
+
style={[
|
|
133
|
+
styles.container,
|
|
134
|
+
!editable && styles.containerStatic,
|
|
135
|
+
isInvalid && styles.containerError,
|
|
136
|
+
disabled && styles.containerDisabled,
|
|
137
|
+
]}
|
|
138
|
+
aria-describedby={
|
|
139
|
+
fieldCtx
|
|
140
|
+
? `${fieldCtx.descriptionId} ${fieldCtx.invalid ? fieldCtx.errorId : ''}`.trim()
|
|
141
|
+
: undefined
|
|
142
|
+
}
|
|
143
|
+
aria-invalid={isInvalid || undefined}
|
|
144
|
+
>
|
|
145
|
+
{children}
|
|
146
|
+
</html.div>
|
|
147
|
+
|
|
148
|
+
{/* Hidden inputs for form submission */}
|
|
149
|
+
{name && values.map((v) => <html.input key={v} type="hidden" name={name} value={v} />)}
|
|
150
|
+
|
|
151
|
+
{/* Live region for screen reader announcements */}
|
|
152
|
+
<html.div role="status" aria-live="polite" aria-atomic style={styles.liveRegion}>
|
|
153
|
+
{announcement}
|
|
154
|
+
</html.div>
|
|
155
|
+
</html.div>
|
|
156
|
+
</TagGroupContext.Provider>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// TagGroup.List
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
interface ListProps {
|
|
165
|
+
children: ReactNode
|
|
166
|
+
'aria-label'?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function List({children, 'aria-label': ariaLabel}: ListProps) {
|
|
170
|
+
const ctx = useTagGroupCtx()
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<html.div role={'grid' as any} aria-label={ariaLabel} id={ctx?.gridId} style={styles.list}>
|
|
174
|
+
{children}
|
|
175
|
+
</html.div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// TagGroup.Input
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
interface InputProps {
|
|
184
|
+
placeholder?: string
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function Input({placeholder}: InputProps) {
|
|
188
|
+
const ctx = useTagGroupCtx()
|
|
189
|
+
const [inputValue, setInputValue] = useState('')
|
|
190
|
+
const localRef = useRef<HTMLInputElement>(null)
|
|
191
|
+
|
|
192
|
+
// Sync local ref with context inputRef
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (ctx?.inputRef) {
|
|
195
|
+
;(ctx.inputRef as React.MutableRefObject<HTMLInputElement | null>).current = localRef.current
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// Register as editable on mount
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
ctx?.setEditable(true)
|
|
202
|
+
return () => ctx?.setEditable(false)
|
|
203
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
204
|
+
|
|
205
|
+
const commitValueRef = useRef<(raw: string) => boolean>(() => false)
|
|
206
|
+
commitValueRef.current = (raw: string) => {
|
|
207
|
+
const trimmed = raw.trim()
|
|
208
|
+
if (!trimmed || !ctx) return false
|
|
209
|
+
ctx.addTag(trimmed)
|
|
210
|
+
setInputValue('')
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Native keydown for full KeyboardEvent access (preventDefault)
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const el = localRef.current
|
|
217
|
+
if (!el) return
|
|
218
|
+
|
|
219
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
220
|
+
if (!ctx) return
|
|
221
|
+
const target = e.target as HTMLInputElement
|
|
222
|
+
|
|
223
|
+
if (e.key === 'Enter' || e.key === ',' || e.key === ';') {
|
|
224
|
+
e.preventDefault()
|
|
225
|
+
commitValueRef.current(target.value)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (e.key === 'Tab' && target.value.trim()) {
|
|
230
|
+
e.preventDefault()
|
|
231
|
+
commitValueRef.current(target.value)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (e.key === 'Backspace' && !target.value && ctx.values.length > 0) {
|
|
236
|
+
e.preventDefault()
|
|
237
|
+
ctx.removeTag(ctx.values[ctx.values.length - 1])
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (e.key === 'ArrowLeft' && (target.selectionStart ?? 0) === 0 && ctx.values.length > 0) {
|
|
242
|
+
e.preventDefault()
|
|
243
|
+
ctx.setFocusedIndex(ctx.values.length - 1)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (e.key === 'Escape') {
|
|
248
|
+
setInputValue('')
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function handlePaste(e: ClipboardEvent) {
|
|
253
|
+
if (!ctx) return
|
|
254
|
+
const text = e.clipboardData?.getData('text') ?? ''
|
|
255
|
+
const items = text
|
|
256
|
+
.split(/[\n,;]+/)
|
|
257
|
+
.map((s) => s.trim())
|
|
258
|
+
.filter(Boolean)
|
|
259
|
+
if (items.length > 1) {
|
|
260
|
+
e.preventDefault()
|
|
261
|
+
for (const item of items) {
|
|
262
|
+
ctx.addTag(item)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function handleFocus() {
|
|
268
|
+
ctx?.setFocusedIndex(-1)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
el.addEventListener('keydown', handleKeyDown)
|
|
272
|
+
el.addEventListener('paste', handlePaste)
|
|
273
|
+
el.addEventListener('focus', handleFocus)
|
|
274
|
+
return () => {
|
|
275
|
+
el.removeEventListener('keydown', handleKeyDown)
|
|
276
|
+
el.removeEventListener('paste', handlePaste)
|
|
277
|
+
el.removeEventListener('focus', handleFocus)
|
|
278
|
+
}
|
|
279
|
+
}, [ctx])
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<html.input
|
|
283
|
+
ref={localRef as React.Ref<HTMLInputElement>}
|
|
284
|
+
type="text"
|
|
285
|
+
value={inputValue}
|
|
286
|
+
onChange={(e: any) => setInputValue(e.target.value)}
|
|
287
|
+
placeholder={placeholder}
|
|
288
|
+
disabled={ctx?.disabled}
|
|
289
|
+
style={styles.input}
|
|
290
|
+
/>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Namespace export
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
export const TagGroup = {
|
|
299
|
+
Root,
|
|
300
|
+
List,
|
|
301
|
+
Input,
|
|
302
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {createContext, useContext} from 'react'
|
|
2
|
+
|
|
3
|
+
export interface TagGroupContextValue {
|
|
4
|
+
values: string[]
|
|
5
|
+
addTag: (value: string) => void
|
|
6
|
+
removeTag: (value: string) => void
|
|
7
|
+
editable: boolean
|
|
8
|
+
setEditable: (v: boolean) => void
|
|
9
|
+
disabled: boolean
|
|
10
|
+
validate?: (value: string) => true | string
|
|
11
|
+
focusedIndex: number
|
|
12
|
+
setFocusedIndex: (index: number) => void
|
|
13
|
+
inputRef: React.RefObject<HTMLInputElement | null>
|
|
14
|
+
gridId: string
|
|
15
|
+
name?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const TagGroupContext = createContext<TagGroupContextValue | null>(null)
|
|
19
|
+
|
|
20
|
+
export function useTagGroup(): TagGroupContextValue | null {
|
|
21
|
+
return useContext(TagGroupContext)
|
|
22
|
+
}
|