@duro-app/ui 0.24.0 → 0.25.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/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
- package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
- package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts +44 -0
- package/dist/components/Combobox/Combobox.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.meta.d.ts +3 -0
- package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.stories.d.ts +9 -0
- package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -0
- package/dist/components/Combobox/ComboboxContext.d.ts +19 -0
- package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -0
- package/dist/components/Combobox/styles.css.d.ts +112 -0
- package/dist/components/Combobox/styles.css.d.ts.map +1 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts +15 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -0
- package/dist/components/Field/Field.d.ts +4 -0
- package/dist/components/Field/Field.d.ts.map +1 -1
- package/dist/components/Field/Field.stories.d.ts +3 -0
- package/dist/components/Field/Field.stories.d.ts.map +1 -1
- package/dist/components/Field/FieldContext.d.ts +5 -0
- package/dist/components/Field/FieldContext.d.ts.map +1 -1
- package/dist/components/Field/styles.css.d.ts +26 -0
- package/dist/components/Field/styles.css.d.ts.map +1 -1
- package/dist/components/Form/Form.d.ts +7 -2
- package/dist/components/Form/Form.d.ts.map +1 -1
- package/dist/components/Form/Form.stories.d.ts +6 -0
- package/dist/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/components/Form/FormContext.d.ts +9 -2
- package/dist/components/Form/FormContext.d.ts.map +1 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -1
- package/dist/components/Table/SortIndicator.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Table/useDataTable.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- 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 +2964 -2381
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ButtonGroup/ButtonGroup.meta.ts +10 -2
- package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
- package/src/components/Combobox/Combobox.meta.ts +35 -0
- package/src/components/Combobox/Combobox.stories.tsx +119 -0
- package/src/components/Combobox/Combobox.tsx +247 -0
- package/src/components/Combobox/ComboboxContext.ts +26 -0
- package/src/components/Combobox/styles.css.ts +119 -0
- package/src/components/Combobox/useComboboxRoot.ts +235 -0
- package/src/components/Field/Field.stories.tsx +60 -0
- package/src/components/Field/Field.tsx +97 -9
- package/src/components/Field/FieldContext.ts +5 -0
- package/src/components/Field/styles.css.ts +26 -0
- package/src/components/Form/Form.stories.tsx +275 -0
- package/src/components/Form/Form.tsx +73 -7
- package/src/components/Form/FormContext.ts +10 -1
- package/src/components/Input/Input.tsx +2 -1
- package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
- package/src/components/RadioGroup/RadioGroup.tsx +1 -4
- package/src/components/Table/SortIndicator.tsx +1 -4
- package/src/components/Table/Table.stories.tsx +135 -84
- package/src/components/Table/Table.tsx +10 -5
- package/src/components/Table/useDataTable.ts +34 -48
- package/src/components/Textarea/Textarea.tsx +2 -1
- package/src/index.ts +2 -0
- package/src/strict.css +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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.25.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -14,8 +14,16 @@ export const meta: ComponentMeta = {
|
|
|
14
14
|
'Navigation items — use Inline or SideNav',
|
|
15
15
|
],
|
|
16
16
|
relatedTo: [
|
|
17
|
-
{
|
|
18
|
-
|
|
17
|
+
{
|
|
18
|
+
component: 'Inline',
|
|
19
|
+
relationship:
|
|
20
|
+
'Inline is a general-purpose horizontal layout; ButtonGroup adds role="group" and disabled support',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
component: 'ActionBar',
|
|
24
|
+
relationship:
|
|
25
|
+
'ActionBar is a floating bulk-selection toolbar; ButtonGroup is a static grouping',
|
|
26
|
+
},
|
|
19
27
|
],
|
|
20
28
|
example: `<ButtonGroup align="end" gap="sm">
|
|
21
29
|
<Button variant="secondary">Cancel</Button>
|
|
@@ -13,7 +13,10 @@ export const meta: ComponentMeta = {
|
|
|
13
13
|
'Many options (10+) — consider a filterable list or Select with multi-select',
|
|
14
14
|
],
|
|
15
15
|
relatedTo: [
|
|
16
|
-
{
|
|
16
|
+
{
|
|
17
|
+
component: 'RadioGroup',
|
|
18
|
+
relationship: 'RadioGroup for single-select; CheckboxGroup for multi-select',
|
|
19
|
+
},
|
|
17
20
|
{component: 'Checkbox', relationship: 'CheckboxGroup.Item wraps Checkbox internally'},
|
|
18
21
|
{component: 'ToggleGroup', relationship: 'ToggleGroup for toolbar-style multi-select'},
|
|
19
22
|
],
|
|
@@ -46,9 +46,7 @@ function Root({
|
|
|
46
46
|
const onToggle = useCallback(
|
|
47
47
|
(itemValue: string, checked: boolean) => {
|
|
48
48
|
if (disabled) return
|
|
49
|
-
const next = checked
|
|
50
|
-
? [...value, itemValue]
|
|
51
|
-
: value.filter((v) => v !== itemValue)
|
|
49
|
+
const next = checked ? [...value, itemValue] : value.filter((v) => v !== itemValue)
|
|
52
50
|
setValue(next)
|
|
53
51
|
},
|
|
54
52
|
[disabled, value, setValue],
|
|
@@ -88,12 +86,7 @@ function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
|
|
|
88
86
|
)
|
|
89
87
|
|
|
90
88
|
return (
|
|
91
|
-
<Checkbox
|
|
92
|
-
value={value}
|
|
93
|
-
checked={isChecked}
|
|
94
|
-
disabled={isDisabled}
|
|
95
|
-
onChange={handleChange}
|
|
96
|
-
>
|
|
89
|
+
<Checkbox value={value} checked={isChecked} disabled={isDisabled} onChange={handleChange}>
|
|
97
90
|
{children}
|
|
98
91
|
</Checkbox>
|
|
99
92
|
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Searchable dropdown for selecting a value from a filterable list. Combines a text input with a popup listbox. Compound component — Root is required.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Large option lists where users benefit from typing to filter (e.g. country, city, user picker)',
|
|
8
|
+
'Autocomplete or typeahead scenarios',
|
|
9
|
+
'Table column filters with known categorical values',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Small lists (fewer than 7 options) — use Select instead',
|
|
13
|
+
'Free-form text entry — use Input',
|
|
14
|
+
'Multiple selections — use CheckboxGroup or ToggleGroup',
|
|
15
|
+
],
|
|
16
|
+
anatomy: {
|
|
17
|
+
required: ['Root', 'Input', 'Popup', 'Item'],
|
|
18
|
+
optional: ['Empty'],
|
|
19
|
+
},
|
|
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'},
|
|
23
|
+
{component: 'Menu', relationship: 'Menu triggers actions; Combobox picks a value'},
|
|
24
|
+
],
|
|
25
|
+
example: `<Combobox.Root name="country" defaultValue="us" initialLabels={{us: 'United States'}}>
|
|
26
|
+
<Combobox.Input placeholder="Search countries…" />
|
|
27
|
+
<Combobox.Popup>
|
|
28
|
+
<Combobox.Item value="us">United States</Combobox.Item>
|
|
29
|
+
<Combobox.Item value="gb">United Kingdom</Combobox.Item>
|
|
30
|
+
<Combobox.Item value="fr">France</Combobox.Item>
|
|
31
|
+
<Combobox.Item value="de">Germany</Combobox.Item>
|
|
32
|
+
<Combobox.Empty>No countries found</Combobox.Empty>
|
|
33
|
+
</Combobox.Popup>
|
|
34
|
+
</Combobox.Root>`,
|
|
35
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {useState} from 'react'
|
|
2
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
3
|
+
import {expect} from 'storybook/test'
|
|
4
|
+
import {Combobox} from './Combobox'
|
|
5
|
+
|
|
6
|
+
const meta: Meta = {
|
|
7
|
+
title: 'Components/Combobox',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default meta
|
|
11
|
+
type Story = StoryObj
|
|
12
|
+
|
|
13
|
+
const fruits = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape', 'Honeydew']
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<Combobox.Root>
|
|
18
|
+
<Combobox.Input placeholder="Search fruit...">
|
|
19
|
+
<Combobox.Trigger />
|
|
20
|
+
</Combobox.Input>
|
|
21
|
+
<Combobox.Popup>
|
|
22
|
+
{fruits.map((fruit) => (
|
|
23
|
+
<Combobox.Item key={fruit} value={fruit.toLowerCase()}>
|
|
24
|
+
<Combobox.ItemText>{fruit}</Combobox.ItemText>
|
|
25
|
+
</Combobox.Item>
|
|
26
|
+
))}
|
|
27
|
+
<Combobox.Empty>No fruits found</Combobox.Empty>
|
|
28
|
+
</Combobox.Popup>
|
|
29
|
+
</Combobox.Root>
|
|
30
|
+
),
|
|
31
|
+
play: async ({canvas}) => {
|
|
32
|
+
const input = canvas.getByRole('combobox')
|
|
33
|
+
await expect(input).toBeInTheDocument()
|
|
34
|
+
await expect(input).toHaveAttribute('aria-expanded', 'false')
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const WithDefaultValue: Story = {
|
|
39
|
+
render: () => (
|
|
40
|
+
<Combobox.Root defaultValue="cherry" initialLabels={{cherry: 'Cherry'}}>
|
|
41
|
+
<Combobox.Input placeholder="Search fruit...">
|
|
42
|
+
<Combobox.Trigger />
|
|
43
|
+
</Combobox.Input>
|
|
44
|
+
<Combobox.Popup>
|
|
45
|
+
{fruits.map((fruit) => (
|
|
46
|
+
<Combobox.Item key={fruit} value={fruit.toLowerCase()}>
|
|
47
|
+
<Combobox.ItemText>{fruit}</Combobox.ItemText>
|
|
48
|
+
</Combobox.Item>
|
|
49
|
+
))}
|
|
50
|
+
<Combobox.Empty>No fruits found</Combobox.Empty>
|
|
51
|
+
</Combobox.Popup>
|
|
52
|
+
</Combobox.Root>
|
|
53
|
+
),
|
|
54
|
+
play: async ({canvas}) => {
|
|
55
|
+
const input = canvas.getByRole('combobox')
|
|
56
|
+
await expect(input).toHaveValue('Cherry')
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ControlledDemo() {
|
|
61
|
+
const [value, setValue] = useState<string | null>(null)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
|
|
65
|
+
<Combobox.Root value={value} onValueChange={setValue}>
|
|
66
|
+
<Combobox.Input placeholder="Pick a role...">
|
|
67
|
+
<Combobox.Trigger />
|
|
68
|
+
</Combobox.Input>
|
|
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>
|
|
75
|
+
<Combobox.Empty>No roles found</Combobox.Empty>
|
|
76
|
+
</Combobox.Popup>
|
|
77
|
+
</Combobox.Root>
|
|
78
|
+
<span style={{fontSize: 14, color: '#888'}}>
|
|
79
|
+
Selected: {value ?? 'none'}
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const Controlled: Story = {
|
|
86
|
+
render: () => <ControlledDemo />,
|
|
87
|
+
play: async ({canvas}) => {
|
|
88
|
+
await expect(canvas.getByText('Selected: none')).toBeInTheDocument()
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const ManyItems: Story = {
|
|
93
|
+
render: () => {
|
|
94
|
+
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',
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Combobox.Root>
|
|
105
|
+
<Combobox.Input placeholder="Search country...">
|
|
106
|
+
<Combobox.Trigger />
|
|
107
|
+
</Combobox.Input>
|
|
108
|
+
<Combobox.Popup>
|
|
109
|
+
{countries.map((country) => (
|
|
110
|
+
<Combobox.Item key={country} value={country.toLowerCase()}>
|
|
111
|
+
<Combobox.ItemText>{country}</Combobox.ItemText>
|
|
112
|
+
</Combobox.Item>
|
|
113
|
+
))}
|
|
114
|
+
<Combobox.Empty>No countries found</Combobox.Empty>
|
|
115
|
+
</Combobox.Popup>
|
|
116
|
+
</Combobox.Root>
|
|
117
|
+
)
|
|
118
|
+
},
|
|
119
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {type ReactNode, useRef, useId, useEffect, useState} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {styles} from './styles.css'
|
|
4
|
+
import {ComboboxContext, useCombobox} from './ComboboxContext'
|
|
5
|
+
import {useComboboxRoot} from './useComboboxRoot'
|
|
6
|
+
|
|
7
|
+
// --- Root ---
|
|
8
|
+
interface RootProps {
|
|
9
|
+
name?: string
|
|
10
|
+
defaultValue?: string
|
|
11
|
+
value?: string
|
|
12
|
+
onValueChange?: (value: string | null) => void
|
|
13
|
+
inputValue?: string
|
|
14
|
+
onInputChange?: (value: string) => void
|
|
15
|
+
initialLabels?: Record<string, string>
|
|
16
|
+
children: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Root({
|
|
20
|
+
name,
|
|
21
|
+
defaultValue,
|
|
22
|
+
value,
|
|
23
|
+
onValueChange,
|
|
24
|
+
inputValue,
|
|
25
|
+
onInputChange,
|
|
26
|
+
initialLabels,
|
|
27
|
+
children,
|
|
28
|
+
}: RootProps) {
|
|
29
|
+
const {ctx, rootRef} = useComboboxRoot({
|
|
30
|
+
defaultValue,
|
|
31
|
+
value,
|
|
32
|
+
onValueChange,
|
|
33
|
+
inputValue,
|
|
34
|
+
onInputChange,
|
|
35
|
+
initialLabels,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<ComboboxContext.Provider value={ctx}>
|
|
40
|
+
<html.div ref={rootRef} style={styles.root}>
|
|
41
|
+
{name && <html.input type="hidden" name={name} value={ctx.value ?? ''} />}
|
|
42
|
+
{children}
|
|
43
|
+
</html.div>
|
|
44
|
+
</ComboboxContext.Provider>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Input ---
|
|
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()
|
|
60
|
+
const localRef = useRef<HTMLInputElement>(null)
|
|
61
|
+
const [focused, setFocused] = useState(false)
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
inputRef.current = localRef.current
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<html.div style={[styles.inputWrapper, focused && styles.inputWrapperFocused]}>
|
|
69
|
+
<html.input
|
|
70
|
+
ref={localRef}
|
|
71
|
+
type="text"
|
|
72
|
+
role={'combobox' as 'listbox'}
|
|
73
|
+
value={inputValue}
|
|
74
|
+
placeholder={placeholder}
|
|
75
|
+
aria-expanded={open}
|
|
76
|
+
aria-haspopup="listbox"
|
|
77
|
+
aria-controls={open ? listboxId : undefined}
|
|
78
|
+
aria-activedescendant={highlightedId ?? undefined}
|
|
79
|
+
aria-autocomplete={'list' as 'none'}
|
|
80
|
+
autoComplete="off"
|
|
81
|
+
style={styles.input}
|
|
82
|
+
onFocus={() => {
|
|
83
|
+
setFocused(true)
|
|
84
|
+
openPopup()
|
|
85
|
+
}}
|
|
86
|
+
onBlur={() => {
|
|
87
|
+
setFocused(false)
|
|
88
|
+
// Delay close to allow item click to register
|
|
89
|
+
setTimeout(close, 150)
|
|
90
|
+
}}
|
|
91
|
+
onChange={(e: any) => {
|
|
92
|
+
setInputValue(e.target.value)
|
|
93
|
+
if (!open) openPopup()
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
{children}
|
|
97
|
+
</html.div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Trigger ---
|
|
102
|
+
function Trigger({children}: {children?: ReactNode}) {
|
|
103
|
+
const {open, openPopup, close, inputRef} = useCombobox()
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<html.button
|
|
107
|
+
type="button"
|
|
108
|
+
tabIndex={-1}
|
|
109
|
+
aria-label={open ? 'Close' : 'Open'}
|
|
110
|
+
onClick={() => {
|
|
111
|
+
if (open) {
|
|
112
|
+
close()
|
|
113
|
+
} else {
|
|
114
|
+
openPopup()
|
|
115
|
+
}
|
|
116
|
+
inputRef.current?.focus()
|
|
117
|
+
}}
|
|
118
|
+
style={styles.trigger}
|
|
119
|
+
>
|
|
120
|
+
{children ?? (
|
|
121
|
+
<svg
|
|
122
|
+
width="12"
|
|
123
|
+
height="12"
|
|
124
|
+
viewBox="0 0 24 24"
|
|
125
|
+
fill="none"
|
|
126
|
+
stroke="currentColor"
|
|
127
|
+
strokeWidth="2"
|
|
128
|
+
strokeLinecap="round"
|
|
129
|
+
strokeLinejoin="round"
|
|
130
|
+
aria-hidden="true"
|
|
131
|
+
>
|
|
132
|
+
<path d="M6 9l6 6 6-6" />
|
|
133
|
+
</svg>
|
|
134
|
+
)}
|
|
135
|
+
</html.button>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- Popup ---
|
|
140
|
+
function Popup({children}: {children: ReactNode}) {
|
|
141
|
+
const {open, listboxId} = useCombobox()
|
|
142
|
+
|
|
143
|
+
if (!open) return null
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<html.div
|
|
147
|
+
id={listboxId}
|
|
148
|
+
role="listbox"
|
|
149
|
+
style={styles.popup}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</html.div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Item ---
|
|
157
|
+
interface ItemProps {
|
|
158
|
+
value: string
|
|
159
|
+
children: ReactNode
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function Item({value: itemValue, children}: ItemProps) {
|
|
163
|
+
const {
|
|
164
|
+
value: selectedValue,
|
|
165
|
+
setValue,
|
|
166
|
+
inputValue,
|
|
167
|
+
labels,
|
|
168
|
+
registerLabel,
|
|
169
|
+
highlightedId,
|
|
170
|
+
setHighlightedId,
|
|
171
|
+
registerItem,
|
|
172
|
+
} = useCombobox()
|
|
173
|
+
const id = useId()
|
|
174
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
175
|
+
const isSelected = selectedValue === itemValue
|
|
176
|
+
const isHighlighted = highlightedId === id
|
|
177
|
+
|
|
178
|
+
// Filter: check if this item matches the input
|
|
179
|
+
const label = labels[itemValue] ?? itemValue
|
|
180
|
+
const query = inputValue.toLowerCase()
|
|
181
|
+
const isVisible = !query || label.toLowerCase().includes(query)
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const el = ref.current
|
|
185
|
+
if (!el) return
|
|
186
|
+
const text = el.textContent
|
|
187
|
+
if (text) registerLabel(itemValue, text)
|
|
188
|
+
}, [itemValue, registerLabel])
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
const el = ref.current
|
|
192
|
+
if (!el) return
|
|
193
|
+
return registerItem(id, itemValue, el)
|
|
194
|
+
}, [id, itemValue, registerItem])
|
|
195
|
+
|
|
196
|
+
if (!isVisible) return null
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<html.div
|
|
200
|
+
ref={ref}
|
|
201
|
+
id={id}
|
|
202
|
+
role="option"
|
|
203
|
+
aria-selected={isSelected}
|
|
204
|
+
onClick={() => setValue(itemValue)}
|
|
205
|
+
onPointerEnter={() => setHighlightedId(id)}
|
|
206
|
+
style={[
|
|
207
|
+
styles.item,
|
|
208
|
+
isSelected && styles.itemSelected,
|
|
209
|
+
isHighlighted && styles.itemHighlighted,
|
|
210
|
+
]}
|
|
211
|
+
>
|
|
212
|
+
{children}
|
|
213
|
+
</html.div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- ItemText ---
|
|
218
|
+
function ItemText({children}: {children: ReactNode}) {
|
|
219
|
+
return <html.span>{children}</html.span>
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Empty ---
|
|
223
|
+
function Empty({children}: {children: ReactNode}) {
|
|
224
|
+
const {open, inputValue, labels} = useCombobox()
|
|
225
|
+
|
|
226
|
+
if (!open) return null
|
|
227
|
+
|
|
228
|
+
// Check if any items are visible
|
|
229
|
+
const query = inputValue.toLowerCase()
|
|
230
|
+
const hasVisibleItems = query
|
|
231
|
+
? Object.values(labels).some((label) => label.toLowerCase().includes(query))
|
|
232
|
+
: true
|
|
233
|
+
|
|
234
|
+
if (hasVisibleItems) return null
|
|
235
|
+
|
|
236
|
+
return <html.div style={styles.empty}>{children}</html.div>
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const Combobox = {
|
|
240
|
+
Root,
|
|
241
|
+
Input,
|
|
242
|
+
Trigger,
|
|
243
|
+
Popup,
|
|
244
|
+
Item,
|
|
245
|
+
ItemText,
|
|
246
|
+
Empty,
|
|
247
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {createContext, useContext} from 'react'
|
|
2
|
+
|
|
3
|
+
export interface ComboboxContextValue {
|
|
4
|
+
open: boolean
|
|
5
|
+
openPopup: () => void
|
|
6
|
+
close: () => void
|
|
7
|
+
value: string | null
|
|
8
|
+
setValue: (value: string) => void
|
|
9
|
+
inputValue: string
|
|
10
|
+
setInputValue: (value: string) => void
|
|
11
|
+
labels: Record<string, string>
|
|
12
|
+
registerLabel: (value: string, label: string) => void
|
|
13
|
+
listboxId: string
|
|
14
|
+
highlightedId: string | null
|
|
15
|
+
setHighlightedId: (id: string | null) => void
|
|
16
|
+
registerItem: (id: string, value: string, element: HTMLElement) => () => void
|
|
17
|
+
inputRef: React.RefObject<HTMLInputElement | null>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const ComboboxContext = createContext<ComboboxContextValue | null>(null)
|
|
21
|
+
|
|
22
|
+
export function useCombobox() {
|
|
23
|
+
const ctx = useContext(ComboboxContext)
|
|
24
|
+
if (!ctx) throw new Error('Combobox compound components must be used within Combobox.Root')
|
|
25
|
+
return ctx
|
|
26
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
import {shadows} from '@duro-app/tokens/tokens/shadows.css'
|
|
6
|
+
|
|
7
|
+
export const styles = css.create({
|
|
8
|
+
root: {
|
|
9
|
+
position: 'relative',
|
|
10
|
+
display: 'inline-flex',
|
|
11
|
+
},
|
|
12
|
+
inputWrapper: {
|
|
13
|
+
display: 'inline-flex',
|
|
14
|
+
alignItems: 'center',
|
|
15
|
+
gap: spacing.sm,
|
|
16
|
+
paddingLeft: spacing.md,
|
|
17
|
+
paddingRight: spacing.xs,
|
|
18
|
+
backgroundColor: colors.bgCard,
|
|
19
|
+
borderWidth: 1,
|
|
20
|
+
borderStyle: 'solid',
|
|
21
|
+
borderColor: {
|
|
22
|
+
default: colors.border,
|
|
23
|
+
':hover': colors.accent,
|
|
24
|
+
},
|
|
25
|
+
borderRadius: radii.sm,
|
|
26
|
+
transitionProperty: 'border-color',
|
|
27
|
+
transitionDuration: '150ms',
|
|
28
|
+
},
|
|
29
|
+
inputWrapperFocused: {
|
|
30
|
+
borderColor: colors.accent,
|
|
31
|
+
},
|
|
32
|
+
input: {
|
|
33
|
+
flex: 1,
|
|
34
|
+
paddingTop: spacing.sm,
|
|
35
|
+
paddingBottom: spacing.sm,
|
|
36
|
+
fontFamily: typography.fontFamily,
|
|
37
|
+
fontSize: typography.fontSizeSm,
|
|
38
|
+
color: colors.text,
|
|
39
|
+
backgroundColor: 'transparent',
|
|
40
|
+
borderWidth: 0,
|
|
41
|
+
outline: 'none',
|
|
42
|
+
minWidth: 0,
|
|
43
|
+
},
|
|
44
|
+
trigger: {
|
|
45
|
+
display: 'flex',
|
|
46
|
+
alignItems: 'center',
|
|
47
|
+
justifyContent: 'center',
|
|
48
|
+
paddingTop: spacing.xs,
|
|
49
|
+
paddingBottom: spacing.xs,
|
|
50
|
+
paddingLeft: spacing.xs,
|
|
51
|
+
paddingRight: spacing.xs,
|
|
52
|
+
backgroundColor: 'transparent',
|
|
53
|
+
borderWidth: 0,
|
|
54
|
+
color: colors.textMuted,
|
|
55
|
+
cursor: 'pointer',
|
|
56
|
+
},
|
|
57
|
+
backdrop: {
|
|
58
|
+
position: 'fixed',
|
|
59
|
+
top: 0,
|
|
60
|
+
left: 0,
|
|
61
|
+
right: 0,
|
|
62
|
+
bottom: 0,
|
|
63
|
+
zIndex: 49,
|
|
64
|
+
},
|
|
65
|
+
popup: {
|
|
66
|
+
position: 'absolute',
|
|
67
|
+
top: '100%',
|
|
68
|
+
left: 0,
|
|
69
|
+
right: 0,
|
|
70
|
+
marginTop: spacing.xs,
|
|
71
|
+
backgroundColor: colors.bgCard,
|
|
72
|
+
borderWidth: 1,
|
|
73
|
+
borderStyle: 'solid',
|
|
74
|
+
borderColor: colors.border,
|
|
75
|
+
borderRadius: radii.sm,
|
|
76
|
+
boxShadow: shadows.md,
|
|
77
|
+
paddingTop: spacing.xs,
|
|
78
|
+
paddingBottom: spacing.xs,
|
|
79
|
+
maxHeight: 200,
|
|
80
|
+
overflowY: 'auto',
|
|
81
|
+
zIndex: 50,
|
|
82
|
+
},
|
|
83
|
+
item: {
|
|
84
|
+
display: 'flex',
|
|
85
|
+
alignItems: 'center',
|
|
86
|
+
paddingTop: spacing.sm,
|
|
87
|
+
paddingBottom: spacing.sm,
|
|
88
|
+
paddingLeft: spacing.md,
|
|
89
|
+
paddingRight: spacing.md,
|
|
90
|
+
fontSize: typography.fontSizeSm,
|
|
91
|
+
fontFamily: typography.fontFamily,
|
|
92
|
+
color: colors.text,
|
|
93
|
+
cursor: 'pointer',
|
|
94
|
+
backgroundColor: 'transparent',
|
|
95
|
+
transitionProperty: 'background-color',
|
|
96
|
+
transitionDuration: '150ms',
|
|
97
|
+
},
|
|
98
|
+
itemSelected: {
|
|
99
|
+
color: colors.accent,
|
|
100
|
+
fontWeight: typography.fontWeightMedium,
|
|
101
|
+
},
|
|
102
|
+
itemHighlighted: {
|
|
103
|
+
backgroundColor: colors.bgCardHover,
|
|
104
|
+
},
|
|
105
|
+
itemHidden: {
|
|
106
|
+
display: 'none',
|
|
107
|
+
},
|
|
108
|
+
empty: {
|
|
109
|
+
paddingTop: spacing.sm,
|
|
110
|
+
paddingBottom: spacing.sm,
|
|
111
|
+
paddingLeft: spacing.md,
|
|
112
|
+
paddingRight: spacing.md,
|
|
113
|
+
fontSize: typography.fontSizeSm,
|
|
114
|
+
color: colors.textMuted,
|
|
115
|
+
},
|
|
116
|
+
hidden: {
|
|
117
|
+
display: 'none',
|
|
118
|
+
},
|
|
119
|
+
})
|