@duro-app/ui 0.24.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.
Files changed (96) hide show
  1. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
  2. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
  3. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -1
  4. package/dist/components/Combobox/Combobox.d.ts +44 -0
  5. package/dist/components/Combobox/Combobox.d.ts.map +1 -0
  6. package/dist/components/Combobox/Combobox.meta.d.ts +3 -0
  7. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -0
  8. package/dist/components/Combobox/Combobox.stories.d.ts +9 -0
  9. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -0
  10. package/dist/components/Combobox/ComboboxContext.d.ts +19 -0
  11. package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -0
  12. package/dist/components/Combobox/styles.css.d.ts +112 -0
  13. package/dist/components/Combobox/styles.css.d.ts.map +1 -0
  14. package/dist/components/Combobox/useComboboxRoot.d.ts +15 -0
  15. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -0
  16. package/dist/components/Field/Field.d.ts +4 -0
  17. package/dist/components/Field/Field.d.ts.map +1 -1
  18. package/dist/components/Field/Field.stories.d.ts +3 -0
  19. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  20. package/dist/components/Field/FieldContext.d.ts +5 -0
  21. package/dist/components/Field/FieldContext.d.ts.map +1 -1
  22. package/dist/components/Field/styles.css.d.ts +26 -0
  23. package/dist/components/Field/styles.css.d.ts.map +1 -1
  24. package/dist/components/Form/Form.d.ts +7 -2
  25. package/dist/components/Form/Form.d.ts.map +1 -1
  26. package/dist/components/Form/Form.stories.d.ts +6 -0
  27. package/dist/components/Form/Form.stories.d.ts.map +1 -1
  28. package/dist/components/Form/FormContext.d.ts +9 -2
  29. package/dist/components/Form/FormContext.d.ts.map +1 -1
  30. package/dist/components/Input/Input.d.ts.map +1 -1
  31. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  32. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -1
  33. package/dist/components/Table/SortIndicator.d.ts.map +1 -1
  34. package/dist/components/Table/Table.d.ts.map +1 -1
  35. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  36. package/dist/components/Table/useDataTable.d.ts.map +1 -1
  37. package/dist/components/Tag/Tag.d.ts +18 -0
  38. package/dist/components/Tag/Tag.d.ts.map +1 -0
  39. package/dist/components/Tag/Tag.meta.d.ts +3 -0
  40. package/dist/components/Tag/Tag.meta.d.ts.map +1 -0
  41. package/dist/components/Tag/Tag.stories.d.ts +11 -0
  42. package/dist/components/Tag/Tag.stories.d.ts.map +1 -0
  43. package/dist/components/Tag/styles.css.d.ts +80 -0
  44. package/dist/components/Tag/styles.css.d.ts.map +1 -0
  45. package/dist/components/TagGroup/TagGroup.d.ts +30 -0
  46. package/dist/components/TagGroup/TagGroup.d.ts.map +1 -0
  47. package/dist/components/TagGroup/TagGroup.meta.d.ts +3 -0
  48. package/dist/components/TagGroup/TagGroup.meta.d.ts.map +1 -0
  49. package/dist/components/TagGroup/TagGroup.stories.d.ts +11 -0
  50. package/dist/components/TagGroup/TagGroup.stories.d.ts.map +1 -0
  51. package/dist/components/TagGroup/TagGroupContext.d.ts +17 -0
  52. package/dist/components/TagGroup/TagGroupContext.d.ts.map +1 -0
  53. package/dist/components/TagGroup/styles.css.d.ts +75 -0
  54. package/dist/components/TagGroup/styles.css.d.ts.map +1 -0
  55. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  56. package/dist/index.css +1 -1
  57. package/dist/index.d.ts +4 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +3256 -2318
  60. package/dist/index.js.map +1 -1
  61. package/package.json +2 -2
  62. package/src/components/ButtonGroup/ButtonGroup.meta.ts +10 -2
  63. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
  64. package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
  65. package/src/components/Combobox/Combobox.meta.ts +41 -0
  66. package/src/components/Combobox/Combobox.stories.tsx +157 -0
  67. package/src/components/Combobox/Combobox.tsx +235 -0
  68. package/src/components/Combobox/ComboboxContext.ts +26 -0
  69. package/src/components/Combobox/styles.css.ts +119 -0
  70. package/src/components/Combobox/useComboboxRoot.ts +235 -0
  71. package/src/components/Field/Field.stories.tsx +62 -0
  72. package/src/components/Field/Field.tsx +94 -9
  73. package/src/components/Field/FieldContext.ts +5 -0
  74. package/src/components/Field/styles.css.ts +26 -0
  75. package/src/components/Form/Form.stories.tsx +275 -0
  76. package/src/components/Form/Form.tsx +69 -7
  77. package/src/components/Form/FormContext.ts +10 -1
  78. package/src/components/Input/Input.tsx +2 -1
  79. package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
  80. package/src/components/RadioGroup/RadioGroup.tsx +1 -4
  81. package/src/components/Table/SortIndicator.tsx +1 -4
  82. package/src/components/Table/Table.stories.tsx +190 -84
  83. package/src/components/Table/Table.tsx +10 -5
  84. package/src/components/Table/useDataTable.ts +34 -48
  85. package/src/components/Tag/Tag.meta.ts +22 -0
  86. package/src/components/Tag/Tag.stories.tsx +92 -0
  87. package/src/components/Tag/Tag.tsx +139 -0
  88. package/src/components/Tag/styles.css.ts +99 -0
  89. package/src/components/TagGroup/TagGroup.meta.ts +27 -0
  90. package/src/components/TagGroup/TagGroup.stories.tsx +193 -0
  91. package/src/components/TagGroup/TagGroup.tsx +302 -0
  92. package/src/components/TagGroup/TagGroupContext.ts +22 -0
  93. package/src/components/TagGroup/styles.css.ts +97 -0
  94. package/src/components/Textarea/Textarea.tsx +2 -1
  95. package/src/index.ts +4 -0
  96. package/src/strict.css +1 -1
@@ -0,0 +1,139 @@
1
+ import {type ReactNode, useCallback, useRef} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useTagGroup} from '../TagGroup/TagGroupContext'
4
+ import {styles} from './styles.css'
5
+
6
+ export type TagVariant = 'default' | 'success' | 'warning' | 'error' | 'info'
7
+ export type TagSize = 'sm' | 'md'
8
+
9
+ interface TagProps {
10
+ /** Value identifier — required when used inside TagGroup */
11
+ value?: string
12
+ variant?: TagVariant
13
+ size?: TagSize
14
+ /** Show remove button. Auto-derived from TagGroup context when inside one. */
15
+ removable?: boolean
16
+ /** Called when remove button is clicked. Auto-wired to TagGroup when inside one. */
17
+ onRemove?: () => void
18
+ disabled?: boolean
19
+ children: ReactNode
20
+ }
21
+
22
+ const sizeMap = {
23
+ sm: styles.sizeSm,
24
+ md: styles.sizeMd,
25
+ } as const
26
+
27
+ const removableSizeMap = {
28
+ sm: styles.removableSm,
29
+ md: styles.removableMd,
30
+ } as const
31
+
32
+ export function Tag({
33
+ value,
34
+ variant = 'default',
35
+ size = 'md',
36
+ removable: removableProp,
37
+ onRemove: onRemoveProp,
38
+ disabled: disabledProp = false,
39
+ children,
40
+ }: TagProps) {
41
+ const group = useTagGroup()
42
+ const contentRef = useRef<HTMLElement>(null)
43
+
44
+ const isGrouped = group !== null
45
+ const disabled = disabledProp || (group?.disabled ?? false)
46
+ const removable = removableProp ?? (isGrouped ? group.editable : false)
47
+
48
+ const onRemove = useCallback(() => {
49
+ if (disabled) return
50
+ if (onRemoveProp) {
51
+ onRemoveProp()
52
+ } else if (isGrouped && value !== undefined) {
53
+ group.removeTag(value)
54
+ }
55
+ }, [disabled, onRemoveProp, isGrouped, value, group])
56
+
57
+ const handleKeyDown = useCallback(
58
+ (e: React.KeyboardEvent) => {
59
+ if (disabled) return
60
+ if ((e.key === 'Backspace' || e.key === 'Delete') && removable) {
61
+ e.preventDefault()
62
+ onRemove()
63
+ }
64
+ // Arrow key navigation delegated to TagGroup via context
65
+ if (isGrouped && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
66
+ e.preventDefault()
67
+ const idx = value !== undefined ? group.values.indexOf(value) : -1
68
+ if (e.key === 'ArrowLeft' && idx > 0) {
69
+ group.setFocusedIndex(idx - 1)
70
+ } else if (e.key === 'ArrowRight') {
71
+ if (idx < group.values.length - 1) {
72
+ group.setFocusedIndex(idx + 1)
73
+ } else {
74
+ // Move focus to input
75
+ group.inputRef.current?.focus()
76
+ group.setFocusedIndex(-1)
77
+ }
78
+ }
79
+ }
80
+ },
81
+ [disabled, removable, onRemove, isGrouped, value, group],
82
+ )
83
+
84
+ const removeLabel = contentRef.current?.textContent
85
+ ? `Remove ${contentRef.current.textContent}`
86
+ : 'Remove'
87
+
88
+ const tagContent = (
89
+ <>
90
+ <html.span ref={contentRef} {...(isGrouped ? {role: 'gridcell' as any} : {})}>
91
+ {children}
92
+ </html.span>
93
+ {removable && (
94
+ <html.button
95
+ type="button"
96
+ aria-label={removeLabel}
97
+ disabled={disabled}
98
+ onClick={(e) => {
99
+ ;(e as any).stopPropagation?.()
100
+ onRemove()
101
+ }}
102
+ tabIndex={-1}
103
+ style={[styles.removeButton, disabled && styles.removeButtonDisabled]}
104
+ >
105
+ <svg
106
+ width={size === 'sm' ? 10 : 12}
107
+ height={size === 'sm' ? 10 : 12}
108
+ viewBox="0 0 12 12"
109
+ fill="none"
110
+ >
111
+ <path
112
+ d="M3 3l6 6M9 3l-6 6"
113
+ stroke="currentColor"
114
+ strokeWidth={1.5}
115
+ strokeLinecap="round"
116
+ />
117
+ </svg>
118
+ </html.button>
119
+ )}
120
+ </>
121
+ )
122
+
123
+ return (
124
+ <html.span
125
+ {...(isGrouped ? {role: 'row' as any} : {})}
126
+ tabIndex={isGrouped ? 0 : undefined}
127
+ onKeyDown={isGrouped ? (handleKeyDown as any) : undefined}
128
+ style={[
129
+ styles.base,
130
+ sizeMap[size],
131
+ removable && removableSizeMap[size],
132
+ styles[variant],
133
+ disabled && styles.disabled,
134
+ ]}
135
+ >
136
+ {tagContent}
137
+ </html.span>
138
+ )
139
+ }
@@ -0,0 +1,99 @@
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.xs,
11
+ fontFamily: typography.fontFamily,
12
+ fontWeight: typography.fontWeightMedium,
13
+ lineHeight: 1,
14
+ borderRadius: radii.full,
15
+ whiteSpace: 'nowrap',
16
+ borderWidth: 0,
17
+ outlineWidth: {
18
+ default: 0,
19
+ ':focus-visible': 2,
20
+ },
21
+ outlineStyle: {
22
+ default: 'none',
23
+ ':focus-visible': 'solid',
24
+ },
25
+ outlineColor: {
26
+ default: 'transparent',
27
+ ':focus-visible': colors.accent,
28
+ },
29
+ outlineOffset: {
30
+ default: 0,
31
+ ':focus-visible': 1,
32
+ },
33
+ },
34
+ sizeMd: {
35
+ paddingTop: spacing.xs,
36
+ paddingBottom: spacing.xs,
37
+ paddingLeft: spacing.sm,
38
+ paddingRight: spacing.sm,
39
+ fontSize: typography.fontSizeXs,
40
+ },
41
+ sizeSm: {
42
+ paddingTop: 2,
43
+ paddingBottom: 2,
44
+ paddingLeft: spacing.xs,
45
+ paddingRight: spacing.xs,
46
+ fontSize: '0.625rem',
47
+ },
48
+ // Reduce right padding when removable to keep visual balance with the button
49
+ removableMd: {
50
+ paddingRight: spacing.xs,
51
+ },
52
+ removableSm: {
53
+ paddingRight: 2,
54
+ },
55
+ default: {
56
+ backgroundColor: colors.bgCardHover,
57
+ color: colors.textMuted,
58
+ },
59
+ success: {
60
+ backgroundColor: colors.successBg,
61
+ color: colors.successText,
62
+ },
63
+ warning: {
64
+ backgroundColor: colors.warningBg,
65
+ color: colors.warningText,
66
+ },
67
+ error: {
68
+ backgroundColor: colors.errorBg,
69
+ color: colors.errorText,
70
+ },
71
+ info: {
72
+ backgroundColor: colors.infoBg,
73
+ color: colors.infoText,
74
+ },
75
+ disabled: {
76
+ opacity: 0.5,
77
+ cursor: 'not-allowed',
78
+ },
79
+ removeButton: {
80
+ display: 'inline-flex',
81
+ alignItems: 'center',
82
+ justifyContent: 'center',
83
+ padding: 0,
84
+ borderWidth: 0,
85
+ backgroundColor: 'transparent',
86
+ color: 'inherit',
87
+ cursor: 'pointer',
88
+ borderRadius: radii.full,
89
+ opacity: {
90
+ default: 0.6,
91
+ ':hover': 1,
92
+ },
93
+ outlineWidth: 0,
94
+ },
95
+ removeButtonDisabled: {
96
+ cursor: 'not-allowed',
97
+ opacity: 0.4,
98
+ },
99
+ })
@@ -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.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
+ }