@duro-app/ui 0.25.0 → 0.26.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/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/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 +80 -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 +75 -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 +2602 -2247
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- 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/Form/Form.tsx +1 -5
- 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 +139 -0
- package/src/components/Tag/styles.css.ts +99 -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 +97 -0
- package/src/index.ts +2 -0
|
@@ -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.List aria-label="Tags">
|
|
48
|
+
{tags.map((t) => (
|
|
49
|
+
<Tag key={t} value={t}>
|
|
50
|
+
{t}
|
|
51
|
+
</Tag>
|
|
52
|
+
))}
|
|
53
|
+
</TagGroup.List>
|
|
54
|
+
<TagGroup.Input placeholder="Add tag..." />
|
|
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.List aria-label="Items">
|
|
80
|
+
{tags.map((t) => (
|
|
81
|
+
<Tag key={t} value={t}>
|
|
82
|
+
{t}
|
|
83
|
+
</Tag>
|
|
84
|
+
))}
|
|
85
|
+
</TagGroup.List>
|
|
86
|
+
<TagGroup.Input placeholder="Add..." />
|
|
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
|
+
<TagGroup.Root
|
|
106
|
+
name="emails"
|
|
107
|
+
value={emails}
|
|
108
|
+
onValueChange={setEmails}
|
|
109
|
+
onValidate={(v) => (v.includes('@') ? true : 'Invalid email')}
|
|
110
|
+
>
|
|
111
|
+
<TagGroup.List aria-label="Emails">
|
|
112
|
+
{emails.map((e) => (
|
|
113
|
+
<Tag key={e} value={e}>
|
|
114
|
+
{e}
|
|
115
|
+
</Tag>
|
|
116
|
+
))}
|
|
117
|
+
</TagGroup.List>
|
|
118
|
+
<TagGroup.Input placeholder="Add email..." />
|
|
119
|
+
</TagGroup.Root>
|
|
120
|
+
<Field.Description>Press Enter or comma to add each email</Field.Description>
|
|
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.List aria-label="Frameworks">
|
|
152
|
+
{tags.map((t) => (
|
|
153
|
+
<Tag key={t} value={t}>
|
|
154
|
+
{t}
|
|
155
|
+
</Tag>
|
|
156
|
+
))}
|
|
157
|
+
</TagGroup.List>
|
|
158
|
+
<TagGroup.Input placeholder="Disabled..." />
|
|
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={!ctx?.values.length ? placeholder : undefined}
|
|
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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
root: {
|
|
8
|
+
display: 'flex',
|
|
9
|
+
flexDirection: 'column',
|
|
10
|
+
},
|
|
11
|
+
container: {
|
|
12
|
+
display: 'flex',
|
|
13
|
+
flexWrap: 'wrap',
|
|
14
|
+
alignItems: 'center',
|
|
15
|
+
gap: spacing.xs,
|
|
16
|
+
paddingTop: spacing.xs,
|
|
17
|
+
paddingBottom: spacing.xs,
|
|
18
|
+
paddingLeft: spacing.sm,
|
|
19
|
+
paddingRight: spacing.sm,
|
|
20
|
+
borderWidth: 1,
|
|
21
|
+
borderStyle: 'solid',
|
|
22
|
+
borderColor: {
|
|
23
|
+
default: colors.border,
|
|
24
|
+
':focus-within': colors.accent,
|
|
25
|
+
},
|
|
26
|
+
borderRadius: radii.sm,
|
|
27
|
+
backgroundColor: colors.bg,
|
|
28
|
+
minHeight: 40,
|
|
29
|
+
transitionProperty: 'border-color',
|
|
30
|
+
transitionDuration: '150ms',
|
|
31
|
+
transitionTimingFunction: 'ease',
|
|
32
|
+
outlineWidth: {
|
|
33
|
+
default: 0,
|
|
34
|
+
':focus-within': 2,
|
|
35
|
+
},
|
|
36
|
+
outlineStyle: {
|
|
37
|
+
default: 'none',
|
|
38
|
+
':focus-within': 'solid',
|
|
39
|
+
},
|
|
40
|
+
outlineColor: {
|
|
41
|
+
default: 'transparent',
|
|
42
|
+
':focus-within': colors.accent,
|
|
43
|
+
},
|
|
44
|
+
outlineOffset: {
|
|
45
|
+
default: 0,
|
|
46
|
+
':focus-within': 1,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
containerError: {
|
|
50
|
+
borderColor: {
|
|
51
|
+
default: colors.error,
|
|
52
|
+
':focus-within': colors.error,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
containerDisabled: {
|
|
56
|
+
opacity: 0.5,
|
|
57
|
+
cursor: 'not-allowed',
|
|
58
|
+
},
|
|
59
|
+
// Static display (no Input) — no input-field border
|
|
60
|
+
containerStatic: {
|
|
61
|
+
borderWidth: 0,
|
|
62
|
+
paddingTop: 0,
|
|
63
|
+
paddingBottom: 0,
|
|
64
|
+
paddingLeft: 0,
|
|
65
|
+
paddingRight: 0,
|
|
66
|
+
minHeight: 0,
|
|
67
|
+
backgroundColor: 'transparent',
|
|
68
|
+
outlineWidth: 0,
|
|
69
|
+
},
|
|
70
|
+
list: {
|
|
71
|
+
display: 'flex',
|
|
72
|
+
flexWrap: 'wrap',
|
|
73
|
+
gap: spacing.xs,
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
},
|
|
76
|
+
input: {
|
|
77
|
+
flex: 1,
|
|
78
|
+
minWidth: 120,
|
|
79
|
+
borderWidth: 0,
|
|
80
|
+
backgroundColor: 'transparent',
|
|
81
|
+
fontFamily: typography.fontFamily,
|
|
82
|
+
fontSize: typography.fontSizeSm,
|
|
83
|
+
lineHeight: typography.lineHeight,
|
|
84
|
+
color: colors.text,
|
|
85
|
+
outlineWidth: 0,
|
|
86
|
+
paddingTop: spacing.xs,
|
|
87
|
+
paddingBottom: spacing.xs,
|
|
88
|
+
paddingLeft: spacing.xs,
|
|
89
|
+
paddingRight: spacing.xs,
|
|
90
|
+
},
|
|
91
|
+
liveRegion: {
|
|
92
|
+
position: 'absolute',
|
|
93
|
+
width: 1,
|
|
94
|
+
height: 1,
|
|
95
|
+
overflow: 'hidden',
|
|
96
|
+
},
|
|
97
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,8 @@ export {
|
|
|
43
43
|
type StatusIconName,
|
|
44
44
|
type StatusIconVariant,
|
|
45
45
|
} from './components/StatusIcon/StatusIcon'
|
|
46
|
+
export {Tag, type TagVariant, type TagSize} from './components/Tag/Tag'
|
|
47
|
+
export {TagGroup} from './components/TagGroup/TagGroup'
|
|
46
48
|
export {Table, type TableVariant, type TableSize} from './components/Table/Table'
|
|
47
49
|
// TanStack Table: consumers install @tanstack/react-table directly and use
|
|
48
50
|
// Table.Pagination, Table.SortIndicator, Table.ColumnFilter for styled integration
|