@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.
Files changed (48) hide show
  1. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  2. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -1
  3. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -1
  4. package/dist/components/Field/Field.d.ts.map +1 -1
  5. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  6. package/dist/components/Form/Form.d.ts.map +1 -1
  7. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  8. package/dist/components/Tag/Tag.d.ts +18 -0
  9. package/dist/components/Tag/Tag.d.ts.map +1 -0
  10. package/dist/components/Tag/Tag.meta.d.ts +3 -0
  11. package/dist/components/Tag/Tag.meta.d.ts.map +1 -0
  12. package/dist/components/Tag/Tag.stories.d.ts +11 -0
  13. package/dist/components/Tag/Tag.stories.d.ts.map +1 -0
  14. package/dist/components/Tag/styles.css.d.ts +80 -0
  15. package/dist/components/Tag/styles.css.d.ts.map +1 -0
  16. package/dist/components/TagGroup/TagGroup.d.ts +30 -0
  17. package/dist/components/TagGroup/TagGroup.d.ts.map +1 -0
  18. package/dist/components/TagGroup/TagGroup.meta.d.ts +3 -0
  19. package/dist/components/TagGroup/TagGroup.meta.d.ts.map +1 -0
  20. package/dist/components/TagGroup/TagGroup.stories.d.ts +11 -0
  21. package/dist/components/TagGroup/TagGroup.stories.d.ts.map +1 -0
  22. package/dist/components/TagGroup/TagGroupContext.d.ts +17 -0
  23. package/dist/components/TagGroup/TagGroupContext.d.ts.map +1 -0
  24. package/dist/components/TagGroup/styles.css.d.ts +75 -0
  25. package/dist/components/TagGroup/styles.css.d.ts.map +1 -0
  26. package/dist/index.css +1 -1
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2602 -2247
  30. package/dist/index.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/components/Combobox/Combobox.meta.ts +8 -2
  33. package/src/components/Combobox/Combobox.stories.tsx +52 -14
  34. package/src/components/Combobox/Combobox.tsx +3 -15
  35. package/src/components/Field/Field.stories.tsx +3 -1
  36. package/src/components/Field/Field.tsx +1 -4
  37. package/src/components/Form/Form.tsx +1 -5
  38. package/src/components/Table/Table.stories.tsx +66 -11
  39. package/src/components/Tag/Tag.meta.ts +22 -0
  40. package/src/components/Tag/Tag.stories.tsx +92 -0
  41. package/src/components/Tag/Tag.tsx +139 -0
  42. package/src/components/Tag/styles.css.ts +99 -0
  43. package/src/components/TagGroup/TagGroup.meta.ts +27 -0
  44. package/src/components/TagGroup/TagGroup.stories.tsx +193 -0
  45. package/src/components/TagGroup/TagGroup.tsx +302 -0
  46. package/src/components/TagGroup/TagGroupContext.ts +22 -0
  47. package/src/components/TagGroup/styles.css.ts +97 -0
  48. package/src/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  }
54
54
  },
55
55
  "dependencies": {
56
- "@duro-app/tokens": "^0.25.0"
56
+ "@duro-app/tokens": "^0.26.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/preset-typescript": "^7.28.0",
@@ -18,8 +18,14 @@ export const meta: ComponentMeta = {
18
18
  optional: ['Empty'],
19
19
  },
20
20
  relatedTo: [
21
- {component: 'Select', relationship: 'Select for short lists without search; Combobox for long filterable lists'},
22
- {component: 'Input', relationship: 'Input for free-form text; Combobox for constrained selection'},
21
+ {
22
+ component: 'Select',
23
+ relationship: 'Select for short lists without search; Combobox for long filterable lists',
24
+ },
25
+ {
26
+ component: 'Input',
27
+ relationship: 'Input for free-form text; Combobox for constrained selection',
28
+ },
23
29
  {component: 'Menu', relationship: 'Menu triggers actions; Combobox picks a value'},
24
30
  ],
25
31
  example: `<Combobox.Root name="country" defaultValue="us" initialLabels={{us: 'United States'}}>
@@ -67,17 +67,25 @@ function ControlledDemo() {
67
67
  <Combobox.Trigger />
68
68
  </Combobox.Input>
69
69
  <Combobox.Popup>
70
- <Combobox.Item value="admin"><Combobox.ItemText>Admin</Combobox.ItemText></Combobox.Item>
71
- <Combobox.Item value="editor"><Combobox.ItemText>Editor</Combobox.ItemText></Combobox.Item>
72
- <Combobox.Item value="viewer"><Combobox.ItemText>Viewer</Combobox.ItemText></Combobox.Item>
73
- <Combobox.Item value="moderator"><Combobox.ItemText>Moderator</Combobox.ItemText></Combobox.Item>
74
- <Combobox.Item value="analyst"><Combobox.ItemText>Analyst</Combobox.ItemText></Combobox.Item>
70
+ <Combobox.Item value="admin">
71
+ <Combobox.ItemText>Admin</Combobox.ItemText>
72
+ </Combobox.Item>
73
+ <Combobox.Item value="editor">
74
+ <Combobox.ItemText>Editor</Combobox.ItemText>
75
+ </Combobox.Item>
76
+ <Combobox.Item value="viewer">
77
+ <Combobox.ItemText>Viewer</Combobox.ItemText>
78
+ </Combobox.Item>
79
+ <Combobox.Item value="moderator">
80
+ <Combobox.ItemText>Moderator</Combobox.ItemText>
81
+ </Combobox.Item>
82
+ <Combobox.Item value="analyst">
83
+ <Combobox.ItemText>Analyst</Combobox.ItemText>
84
+ </Combobox.Item>
75
85
  <Combobox.Empty>No roles found</Combobox.Empty>
76
86
  </Combobox.Popup>
77
87
  </Combobox.Root>
78
- <span style={{fontSize: 14, color: '#888'}}>
79
- Selected: {value ?? 'none'}
80
- </span>
88
+ <span style={{fontSize: 14, color: '#888'}}>Selected: {value ?? 'none'}</span>
81
89
  </div>
82
90
  )
83
91
  }
@@ -92,12 +100,42 @@ export const Controlled: Story = {
92
100
  export const ManyItems: Story = {
93
101
  render: () => {
94
102
  const countries = [
95
- 'Argentina', 'Australia', 'Austria', 'Belgium', 'Brazil', 'Canada',
96
- 'Chile', 'China', 'Colombia', 'Denmark', 'Egypt', 'Finland',
97
- 'France', 'Germany', 'Greece', 'India', 'Indonesia', 'Ireland',
98
- 'Italy', 'Japan', 'Kenya', 'Mexico', 'Netherlands', 'New Zealand',
99
- 'Norway', 'Peru', 'Poland', 'Portugal', 'South Korea', 'Spain',
100
- 'Sweden', 'Switzerland', 'Thailand', 'Turkey', 'United Kingdom', 'United States',
103
+ 'Argentina',
104
+ 'Australia',
105
+ 'Austria',
106
+ 'Belgium',
107
+ 'Brazil',
108
+ 'Canada',
109
+ 'Chile',
110
+ 'China',
111
+ 'Colombia',
112
+ 'Denmark',
113
+ 'Egypt',
114
+ 'Finland',
115
+ 'France',
116
+ 'Germany',
117
+ 'Greece',
118
+ 'India',
119
+ 'Indonesia',
120
+ 'Ireland',
121
+ 'Italy',
122
+ 'Japan',
123
+ 'Kenya',
124
+ 'Mexico',
125
+ 'Netherlands',
126
+ 'New Zealand',
127
+ 'Norway',
128
+ 'Peru',
129
+ 'Poland',
130
+ 'Portugal',
131
+ 'South Korea',
132
+ 'Spain',
133
+ 'Sweden',
134
+ 'Switzerland',
135
+ 'Thailand',
136
+ 'Turkey',
137
+ 'United Kingdom',
138
+ 'United States',
101
139
  ]
102
140
 
103
141
  return (
@@ -47,16 +47,8 @@ function Root({
47
47
 
48
48
  // --- Input ---
49
49
  function Input({placeholder, children}: {placeholder?: string; children?: ReactNode}) {
50
- const {
51
- open,
52
- openPopup,
53
- close,
54
- inputValue,
55
- setInputValue,
56
- listboxId,
57
- highlightedId,
58
- inputRef,
59
- } = useCombobox()
50
+ const {open, openPopup, close, inputValue, setInputValue, listboxId, highlightedId, inputRef} =
51
+ useCombobox()
60
52
  const localRef = useRef<HTMLInputElement>(null)
61
53
  const [focused, setFocused] = useState(false)
62
54
 
@@ -143,11 +135,7 @@ function Popup({children}: {children: ReactNode}) {
143
135
  if (!open) return null
144
136
 
145
137
  return (
146
- <html.div
147
- id={listboxId}
148
- role="listbox"
149
- style={styles.popup}
150
- >
138
+ <html.div id={listboxId} role="listbox" style={styles.popup}>
151
139
  {children}
152
140
  </html.div>
153
141
  )
@@ -105,7 +105,9 @@ export const WithNecessityIcon: Story = {
105
105
  <Field.Root required>
106
106
  <Field.Label>Email</Field.Label>
107
107
  <Input placeholder="you@example.com" />
108
- <Field.Description>Required field with no Form context — indicator needs Form.</Field.Description>
108
+ <Field.Description>
109
+ Required field with no Form context — indicator needs Form.
110
+ </Field.Description>
109
111
  </Field.Root>
110
112
  </html.div>
111
113
  ),
@@ -172,10 +172,7 @@ function Label({children}: LabelProps) {
172
172
  const indicator = ctx?.necessityIndicator
173
173
 
174
174
  return (
175
- <html.label
176
- for={ctx?.controlId}
177
- style={[styles.label, isSide && styles.labelSide]}
178
- >
175
+ <html.label for={ctx?.controlId} style={[styles.label, isSide && styles.labelSide]}>
179
176
  {children}
180
177
  {indicator === 'icon' && ctx?.required && (
181
178
  <html.span style={styles.necessityIcon} aria-hidden={true}>
@@ -11,11 +11,7 @@ import {
11
11
  } from 'react-hook-form'
12
12
  import {effectTsResolver} from '@hookform/resolvers/effect-ts'
13
13
  import type {Schema} from 'effect'
14
- import {
15
- FormContext,
16
- type LabelPosition,
17
- type NecessityIndicator,
18
- } from './FormContext'
14
+ import {FormContext, type LabelPosition, type NecessityIndicator} from './FormContext'
19
15
 
20
16
  export interface FormProps<T extends FieldValues> {
21
17
  schema: Schema.Schema<T>
@@ -9,7 +9,18 @@ import {Button} from '../Button/Button'
9
9
  import {ActionBar} from '../ActionBar/ActionBar'
10
10
  import {colors} from '@duro-app/tokens/tokens/colors.css'
11
11
 
12
- import {flexRender, createColumnHelper, useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, type SortingState, type ColumnFiltersState, type PaginationState} from '@tanstack/react-table'
12
+ import {
13
+ flexRender,
14
+ createColumnHelper,
15
+ useReactTable,
16
+ getCoreRowModel,
17
+ getSortedRowModel,
18
+ getFilteredRowModel,
19
+ getPaginationRowModel,
20
+ type SortingState,
21
+ type ColumnFiltersState,
22
+ type PaginationState,
23
+ } from '@tanstack/react-table'
13
24
  import {useDataTable} from './useDataTable'
14
25
  import {Combobox} from '../Combobox/Combobox'
15
26
  import {spacing} from '@duro-app/tokens/tokens/spacing.css'
@@ -515,16 +526,60 @@ interface User {
515
526
  }
516
527
 
517
528
  const firstNames = [
518
- 'Alice', 'Bob', 'Carol', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank',
519
- 'Iris', 'Jack', 'Karen', 'Leo', 'Mona', 'Nick', 'Olivia', 'Paul',
520
- 'Quinn', 'Rita', 'Sam', 'Tina', 'Uma', 'Vince', 'Wendy', 'Xander',
521
- 'Yara', 'Zach',
529
+ 'Alice',
530
+ 'Bob',
531
+ 'Carol',
532
+ 'Dave',
533
+ 'Eve',
534
+ 'Frank',
535
+ 'Grace',
536
+ 'Hank',
537
+ 'Iris',
538
+ 'Jack',
539
+ 'Karen',
540
+ 'Leo',
541
+ 'Mona',
542
+ 'Nick',
543
+ 'Olivia',
544
+ 'Paul',
545
+ 'Quinn',
546
+ 'Rita',
547
+ 'Sam',
548
+ 'Tina',
549
+ 'Uma',
550
+ 'Vince',
551
+ 'Wendy',
552
+ 'Xander',
553
+ 'Yara',
554
+ 'Zach',
522
555
  ]
523
556
  const lastNames = [
524
- 'Adams', 'Brown', 'Clark', 'Davis', 'Evans', 'Foster', 'Garcia',
525
- 'Hill', 'Ito', 'Jones', 'Kim', 'Lee', 'Moore', 'Nash', 'Owen',
526
- 'Patel', 'Quinn', 'Reed', 'Smith', 'Tran', 'Ueda', 'Voss', 'Wang',
527
- 'Xu', 'Yang', 'Zhang',
557
+ 'Adams',
558
+ 'Brown',
559
+ 'Clark',
560
+ 'Davis',
561
+ 'Evans',
562
+ 'Foster',
563
+ 'Garcia',
564
+ 'Hill',
565
+ 'Ito',
566
+ 'Jones',
567
+ 'Kim',
568
+ 'Lee',
569
+ 'Moore',
570
+ 'Nash',
571
+ 'Owen',
572
+ 'Patel',
573
+ 'Quinn',
574
+ 'Reed',
575
+ 'Smith',
576
+ 'Tran',
577
+ 'Ueda',
578
+ 'Voss',
579
+ 'Wang',
580
+ 'Xu',
581
+ 'Yang',
582
+ 'Zhang',
528
583
  ]
529
584
  const roles = ['Admin', 'Editor', 'Viewer', 'Moderator', 'Analyst']
530
585
  const statuses = ['Active', 'Inactive', 'Pending', 'Suspended']
@@ -729,7 +784,7 @@ function FullFeaturedDemo() {
729
784
  <html.div style={fullFeaturedStyles.wrapper}>
730
785
  <html.div style={fullFeaturedStyles.filterBar}>
731
786
  <Combobox.Root
732
- value={table.getColumn('role')?.getFilterValue() as string ?? ''}
787
+ value={(table.getColumn('role')?.getFilterValue() as string) ?? ''}
733
788
  onValueChange={(v) => table.getColumn('role')?.setFilterValue(v || undefined)}
734
789
  onInputChange={(v) => table.getColumn('role')?.setFilterValue(v || undefined)}
735
790
  >
@@ -749,7 +804,7 @@ function FullFeaturedDemo() {
749
804
  </Combobox.Popup>
750
805
  </Combobox.Root>
751
806
  <Combobox.Root
752
- value={table.getColumn('status')?.getFilterValue() as string ?? ''}
807
+ value={(table.getColumn('status')?.getFilterValue() as string) ?? ''}
753
808
  onValueChange={(v) => table.getColumn('status')?.setFilterValue(v || undefined)}
754
809
  onInputChange={(v) => table.getColumn('status')?.setFilterValue(v || undefined)}
755
810
  >
@@ -0,0 +1,22 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Interactive tag/chip with optional remove button. Works standalone or inside TagGroup for collection management. Visually similar to Badge but supports interaction.',
6
+ whenToUse: [
7
+ 'Removable items in a list (emails, categories)',
8
+ 'Inside TagGroup for editable tag collections',
9
+ 'Interactive labels that can be dismissed',
10
+ ],
11
+ whenNotToUse: [
12
+ 'Static status indicators — use Badge',
13
+ 'Toggleable selection — use Toggle or ToggleGroup',
14
+ ],
15
+ relatedTo: [
16
+ {component: 'Badge', relationship: 'Static display variant'},
17
+ {component: 'TagGroup', relationship: 'Wrap in TagGroup.Root for collection management'},
18
+ ],
19
+ example: `<Tag removable onRemove={() => console.log('removed')}>
20
+ user@example.com
21
+ </Tag>`,
22
+ }
@@ -0,0 +1,92 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Tag} from './Tag'
5
+
6
+ const meta: Meta<typeof Tag> = {
7
+ title: 'Components/Tag',
8
+ component: Tag,
9
+ argTypes: {
10
+ variant: {
11
+ control: 'select',
12
+ options: ['default', 'success', 'warning', 'error', 'info'],
13
+ },
14
+ size: {
15
+ control: 'select',
16
+ options: ['sm', 'md'],
17
+ },
18
+ removable: {control: 'boolean'},
19
+ disabled: {control: 'boolean'},
20
+ },
21
+ }
22
+
23
+ export default meta
24
+ type Story = StoryObj<typeof Tag>
25
+
26
+ export const Default: Story = {
27
+ args: {children: 'Tag'},
28
+ play: async ({canvas}) => {
29
+ await expect(canvas.getByText('Tag')).toBeInTheDocument()
30
+ },
31
+ }
32
+
33
+ export const Removable: Story = {
34
+ args: {children: 'user@example.com', removable: true, onRemove: fn()},
35
+ play: async ({canvas}) => {
36
+ await expect(canvas.getByText('user@example.com')).toBeInTheDocument()
37
+ await expect(canvas.getByRole('button', {name: /remove/i})).toBeInTheDocument()
38
+ },
39
+ }
40
+
41
+ export const Disabled: Story = {
42
+ args: {children: 'Disabled', removable: true, disabled: true},
43
+ }
44
+
45
+ export const Small: Story = {
46
+ args: {children: 'Small', size: 'sm', removable: true, onRemove: fn()},
47
+ }
48
+
49
+ const layoutStyles = css.create({
50
+ row: {display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'},
51
+ stack: {display: 'flex', flexDirection: 'column', gap: 16},
52
+ })
53
+
54
+ export const AllVariants: Story = {
55
+ render: () => (
56
+ <html.div style={layoutStyles.stack}>
57
+ <html.div style={layoutStyles.row}>
58
+ <Tag>Default</Tag>
59
+ <Tag variant="success">Success</Tag>
60
+ <Tag variant="warning">Warning</Tag>
61
+ <Tag variant="error">Error</Tag>
62
+ <Tag variant="info">Info</Tag>
63
+ </html.div>
64
+ <html.div style={layoutStyles.row}>
65
+ <Tag removable onRemove={() => {}}>
66
+ Default
67
+ </Tag>
68
+ <Tag variant="success" removable onRemove={() => {}}>
69
+ Success
70
+ </Tag>
71
+ <Tag variant="warning" removable onRemove={() => {}}>
72
+ Warning
73
+ </Tag>
74
+ <Tag variant="error" removable onRemove={() => {}}>
75
+ Error
76
+ </Tag>
77
+ <Tag variant="info" removable onRemove={() => {}}>
78
+ Info
79
+ </Tag>
80
+ </html.div>
81
+ <html.div style={layoutStyles.row}>
82
+ <Tag size="sm">Small</Tag>
83
+ <Tag size="sm" removable onRemove={() => {}}>
84
+ Removable Small
85
+ </Tag>
86
+ </html.div>
87
+ </html.div>
88
+ ),
89
+ play: async ({canvas}) => {
90
+ await expect(canvas.getAllByText('Default').length).toBe(2)
91
+ },
92
+ }
@@ -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
+ }