@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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.
|
|
56
|
+
"@duro-app/tokens": "^0.27.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
|
-
{
|
|
22
|
-
|
|
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"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<Combobox.Item value="
|
|
74
|
-
|
|
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',
|
|
96
|
-
'
|
|
97
|
-
'
|
|
98
|
-
'
|
|
99
|
-
'
|
|
100
|
-
'
|
|
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
|
-
|
|
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>
|
|
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>
|
|
@@ -28,11 +28,15 @@ interface ViewportProps {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function Viewport({children, maxHeight}: ViewportProps) {
|
|
31
|
-
const {viewportRef} = useScrollArea()
|
|
31
|
+
const {viewportRef, viewportId} = useScrollArea()
|
|
32
32
|
|
|
33
33
|
return (
|
|
34
34
|
<html.div
|
|
35
35
|
ref={viewportRef}
|
|
36
|
+
id={viewportId}
|
|
37
|
+
tabIndex={0}
|
|
38
|
+
role="region"
|
|
39
|
+
aria-label="Scrollable content"
|
|
36
40
|
style={[styles.viewport, maxHeight != null && styles.viewportMaxHeight(maxHeight)]}
|
|
37
41
|
>
|
|
38
42
|
{children}
|
|
@@ -65,7 +69,16 @@ interface ScrollbarProps {
|
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
function Scrollbar({orientation = 'vertical', children}: ScrollbarProps) {
|
|
68
|
-
const {
|
|
72
|
+
const {
|
|
73
|
+
viewportId,
|
|
74
|
+
scrolling,
|
|
75
|
+
scrollTop,
|
|
76
|
+
scrollLeft,
|
|
77
|
+
scrollHeight,
|
|
78
|
+
scrollWidth,
|
|
79
|
+
clientHeight,
|
|
80
|
+
clientWidth,
|
|
81
|
+
} = useScrollArea()
|
|
69
82
|
|
|
70
83
|
// Hide scrollbar when content fits
|
|
71
84
|
const hasOverflow =
|
|
@@ -73,11 +86,22 @@ function Scrollbar({orientation = 'vertical', children}: ScrollbarProps) {
|
|
|
73
86
|
|
|
74
87
|
if (!hasOverflow) return null
|
|
75
88
|
|
|
89
|
+
const isVertical = orientation === 'vertical'
|
|
90
|
+
const maxScroll = isVertical ? scrollHeight - clientHeight : scrollWidth - clientWidth
|
|
91
|
+
const scrollOffset = isVertical ? scrollTop : scrollLeft
|
|
92
|
+
const valueNow = maxScroll > 0 ? Math.round((scrollOffset / maxScroll) * 100) : 0
|
|
93
|
+
|
|
76
94
|
return (
|
|
77
95
|
<html.div
|
|
96
|
+
role="scrollbar"
|
|
97
|
+
aria-controls={viewportId}
|
|
98
|
+
aria-orientation={orientation}
|
|
99
|
+
aria-valuenow={valueNow}
|
|
100
|
+
aria-valuemin={0}
|
|
101
|
+
aria-valuemax={100}
|
|
78
102
|
style={[
|
|
79
103
|
styles.scrollbar,
|
|
80
|
-
|
|
104
|
+
isVertical ? styles.scrollbarVertical : styles.scrollbarHorizontal,
|
|
81
105
|
scrolling ? styles.scrollbarVisible : styles.scrollbarHidden,
|
|
82
106
|
]}
|
|
83
107
|
>
|
|
@@ -3,6 +3,7 @@ import {createContext, useContext} from 'react'
|
|
|
3
3
|
export interface ScrollAreaContextValue {
|
|
4
4
|
viewportRef: React.RefObject<HTMLDivElement | null>
|
|
5
5
|
contentRef: React.RefObject<HTMLDivElement | null>
|
|
6
|
+
viewportId: string
|
|
6
7
|
scrollTop: number
|
|
7
8
|
scrollLeft: number
|
|
8
9
|
scrollHeight: number
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {useState, useCallback, useRef, useEffect} from 'react'
|
|
1
|
+
import {useState, useCallback, useRef, useEffect, useId} from 'react'
|
|
2
2
|
import type {ScrollAreaContextValue} from './ScrollAreaContext'
|
|
3
3
|
|
|
4
4
|
export function useScrollAreaRoot(): ScrollAreaContextValue {
|
|
5
|
+
const viewportId = useId()
|
|
5
6
|
const viewportRef = useRef<HTMLDivElement | null>(null)
|
|
6
7
|
const contentRef = useRef<HTMLDivElement | null>(null)
|
|
7
8
|
const [scrollTop, setScrollTop] = useState(0)
|
|
@@ -61,6 +62,7 @@ export function useScrollAreaRoot(): ScrollAreaContextValue {
|
|
|
61
62
|
return {
|
|
62
63
|
viewportRef,
|
|
63
64
|
contentRef,
|
|
65
|
+
viewportId,
|
|
64
66
|
scrollTop,
|
|
65
67
|
scrollLeft,
|
|
66
68
|
scrollHeight,
|
|
@@ -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 {
|
|
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',
|
|
519
|
-
'
|
|
520
|
-
'
|
|
521
|
-
'
|
|
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',
|
|
525
|
-
'
|
|
526
|
-
'
|
|
527
|
-
'
|
|
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,141 @@
|
|
|
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.span {...(isGrouped ? {role: 'gridcell' as any} : {})}>
|
|
95
|
+
<html.button
|
|
96
|
+
type="button"
|
|
97
|
+
aria-label={removeLabel}
|
|
98
|
+
disabled={disabled}
|
|
99
|
+
onClick={(e) => {
|
|
100
|
+
;(e as any).stopPropagation?.()
|
|
101
|
+
onRemove()
|
|
102
|
+
}}
|
|
103
|
+
tabIndex={-1}
|
|
104
|
+
style={[styles.removeButton, disabled && styles.removeButtonDisabled]}
|
|
105
|
+
>
|
|
106
|
+
<svg
|
|
107
|
+
width={size === 'sm' ? 10 : 12}
|
|
108
|
+
height={size === 'sm' ? 10 : 12}
|
|
109
|
+
viewBox="0 0 12 12"
|
|
110
|
+
fill="none"
|
|
111
|
+
>
|
|
112
|
+
<path
|
|
113
|
+
d="M3 3l6 6M9 3l-6 6"
|
|
114
|
+
stroke="currentColor"
|
|
115
|
+
strokeWidth={1.5}
|
|
116
|
+
strokeLinecap="round"
|
|
117
|
+
/>
|
|
118
|
+
</svg>
|
|
119
|
+
</html.button>
|
|
120
|
+
</html.span>
|
|
121
|
+
)}
|
|
122
|
+
</>
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<html.span
|
|
127
|
+
{...(isGrouped ? {role: 'row' as any} : {})}
|
|
128
|
+
tabIndex={isGrouped ? 0 : undefined}
|
|
129
|
+
onKeyDown={isGrouped ? (handleKeyDown as any) : undefined}
|
|
130
|
+
style={[
|
|
131
|
+
styles.base,
|
|
132
|
+
sizeMap[size],
|
|
133
|
+
removable && removableSizeMap[size],
|
|
134
|
+
styles[variant],
|
|
135
|
+
disabled && styles.disabled,
|
|
136
|
+
]}
|
|
137
|
+
>
|
|
138
|
+
{tagContent}
|
|
139
|
+
</html.span>
|
|
140
|
+
)
|
|
141
|
+
}
|