@thangnqdev/ui 0.1.2 → 0.2.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/LICENSE +21 -0
- package/README.md +54 -206
- package/dist/bento/index.cjs.map +1 -1
- package/dist/bento/index.js +2 -2
- package/dist/bento/styles.css +2 -46
- package/dist/buttons/index.cjs +111 -18
- package/dist/buttons/index.cjs.map +1 -1
- package/dist/buttons/index.d.cts +1 -1
- package/dist/buttons/index.d.ts +1 -1
- package/dist/buttons/index.js +3 -3
- package/dist/buttons/styles.css +2 -6
- package/dist/{chunk-4S6L5V7X.js → chunk-2YJBW4E2.js} +2 -2
- package/dist/chunk-2YJBW4E2.js.map +1 -0
- package/dist/{chunk-F5Q7BTMH.js → chunk-4CRFNALF.js} +1011 -556
- package/dist/chunk-4CRFNALF.js.map +1 -0
- package/dist/{chunk-LWFRUJRU.js → chunk-4MS6ZHFQ.js} +225 -41
- package/dist/chunk-4MS6ZHFQ.js.map +1 -0
- package/dist/{chunk-2OXZK6IP.js → chunk-77KTYOMQ.js} +3 -3
- package/dist/chunk-77KTYOMQ.js.map +1 -0
- package/dist/{chunk-IABPUCAQ.js → chunk-ADO3SVUA.js} +2 -2
- package/dist/chunk-ADO3SVUA.js.map +1 -0
- package/dist/{chunk-GXI2H5XA.js → chunk-D7FB3TCA.js} +3 -3
- package/dist/chunk-D7FB3TCA.js.map +1 -0
- package/dist/chunk-DSEL7ELZ.js +314 -0
- package/dist/chunk-DSEL7ELZ.js.map +1 -0
- package/dist/{chunk-KJRBRTQE.js → chunk-EXFR2BFJ.js} +37 -28
- package/dist/chunk-EXFR2BFJ.js.map +1 -0
- package/dist/{chunk-PF4ZHGVW.js → chunk-GXWB6BSY.js} +116 -23
- package/dist/chunk-GXWB6BSY.js.map +1 -0
- package/dist/{chunk-66VWUJEI.js → chunk-I2FQBMVW.js} +3 -3
- package/dist/chunk-I2FQBMVW.js.map +1 -0
- package/dist/{chunk-EU2LPUYP.js → chunk-I5O2WCGF.js} +3 -3
- package/dist/chunk-I5O2WCGF.js.map +1 -0
- package/dist/{chunk-ATYGDTMK.js → chunk-ME4XILG5.js} +5 -5
- package/dist/chunk-ME4XILG5.js.map +1 -0
- package/dist/{chunk-5FZ6ZFII.js → chunk-PC5SZFZC.js} +373 -150
- package/dist/chunk-PC5SZFZC.js.map +1 -0
- package/dist/{chunk-C4SUXMI3.js → chunk-S6XJMYME.js} +3 -3
- package/dist/chunk-S6XJMYME.js.map +1 -0
- package/dist/{chunk-A7P3O5Z5.js → chunk-V52JOSDF.js} +3 -3
- package/dist/chunk-V52JOSDF.js.map +1 -0
- package/dist/{chunk-NFVONJUK.js → chunk-W5WNWTQC.js} +455 -211
- package/dist/chunk-W5WNWTQC.js.map +1 -0
- package/dist/{chunk-WAFHC4DQ.js → chunk-YJ64RI7J.js} +3 -3
- package/dist/chunk-YJ64RI7J.js.map +1 -0
- package/dist/chunk-YOCGBORV.js +155 -0
- package/dist/chunk-YOCGBORV.js.map +1 -0
- package/dist/chunk-ZEDXARXW.js +96 -0
- package/dist/chunk-ZEDXARXW.js.map +1 -0
- package/dist/components.css +2 -0
- package/dist/core/index.cjs +33 -24
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/core/styles.css +2 -134
- package/dist/data-display/index.cjs +770 -247
- package/dist/data-display/index.cjs.map +1 -1
- package/dist/data-display/index.d.cts +1 -1
- package/dist/data-display/index.d.ts +1 -1
- package/dist/data-display/index.js +5 -4
- package/dist/data-display/styles.css +2 -4
- package/dist/feedback/index.cjs.map +1 -1
- package/dist/feedback/index.js +2 -2
- package/dist/feedback/styles.css +2 -4
- package/dist/forms/index.cjs +1211 -459
- package/dist/forms/index.cjs.map +1 -1
- package/dist/forms/index.d.cts +1 -1
- package/dist/forms/index.d.ts +1 -1
- package/dist/forms/index.js +8 -8
- package/dist/forms/styles.css +2 -6
- package/dist/glass/index.cjs.map +1 -1
- package/dist/glass/index.js +2 -2
- package/dist/glass/styles.css +2 -81
- package/dist/{index-D6fE7w-b.d.cts → index-BGt_hecO.d.cts} +1 -0
- package/dist/{index-D6fE7w-b.d.ts → index-BGt_hecO.d.ts} +1 -0
- package/dist/{index-DzcWh1Hs.d.cts → index-BtDvpZCf.d.cts} +7 -0
- package/dist/{index-DzcWh1Hs.d.ts → index-BtDvpZCf.d.ts} +7 -0
- package/dist/{index-Cle6QzQl.d.ts → index-COX1sBUN.d.ts} +27 -3
- package/dist/{index-BkRqGNG1.d.ts → index-Cg8MuP7u.d.cts} +40 -13
- package/dist/{index-BkRqGNG1.d.cts → index-Cg8MuP7u.d.ts} +40 -13
- package/dist/{index-CenuF_P2.d.ts → index-DHkPRTvE.d.cts} +39 -8
- package/dist/{index-CenuF_P2.d.cts → index-DHkPRTvE.d.ts} +39 -8
- package/dist/{index-TyK-Ldpy.d.cts → index-Du_oht9k.d.cts} +27 -3
- package/dist/index.cjs +2481 -917
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +19 -19
- package/dist/layout/index.cjs.map +1 -1
- package/dist/layout/index.js +2 -2
- package/dist/layout/styles.css +2 -8
- package/dist/navigation/index.cjs +288 -48
- package/dist/navigation/index.cjs.map +1 -1
- package/dist/navigation/index.d.cts +1 -1
- package/dist/navigation/index.d.ts +1 -1
- package/dist/navigation/index.js +6 -6
- package/dist/navigation/styles.css +2 -4
- package/dist/overlays/index.cjs +566 -229
- package/dist/overlays/index.cjs.map +1 -1
- package/dist/overlays/index.d.cts +1 -1
- package/dist/overlays/index.d.ts +1 -1
- package/dist/overlays/index.js +7 -7
- package/dist/overlays/styles.css +2 -8
- package/dist/reset.css +45 -0
- package/dist/styles.css +1 -1
- package/dist/tokens.css +11 -0
- package/dist/typography/index.cjs.map +1 -1
- package/dist/typography/index.js +4 -4
- package/dist/typography/styles.css +2 -5
- package/llms.txt +211 -113
- package/package.json +160 -116
- package/src/bento/BentoCard.tsx +140 -140
- package/src/bento/BentoGrid.tsx +50 -50
- package/src/bento/index.ts +2 -2
- package/src/bento/tokens.css +46 -46
- package/src/buttons/Button.tsx +104 -104
- package/src/buttons/ButtonGroup.tsx +43 -43
- package/src/buttons/IconButton.tsx +79 -79
- package/src/buttons/SplitButton.tsx +186 -86
- package/src/buttons/ToggleButton.tsx +157 -157
- package/src/buttons/index.ts +5 -5
- package/src/buttons/tokens.css +6 -6
- package/src/core/ThemeProvider.tsx +135 -123
- package/src/core/index.ts +2 -2
- package/src/core/tokens.css +134 -134
- package/src/data-display/Accordion.tsx +147 -147
- package/src/data-display/Avatar.tsx +134 -134
- package/src/data-display/Card.tsx +112 -112
- package/src/data-display/Chip.tsx +74 -74
- package/src/data-display/DataTablePro.tsx +299 -121
- package/src/data-display/StatCard.tsx +60 -60
- package/src/data-display/Table.tsx +74 -74
- package/src/data-display/Timeline.tsx +62 -62
- package/src/data-display/TreeView.tsx +243 -104
- package/src/data-display/tokens.css +4 -4
- package/src/feedback/Alert.tsx +80 -80
- package/src/feedback/EmptyState.tsx +45 -45
- package/src/feedback/Skeleton.tsx +66 -66
- package/src/feedback/Spinner.tsx +65 -65
- package/src/feedback/index.ts +5 -5
- package/src/feedback/tokens.css +4 -4
- package/src/forms/Checkbox.tsx +149 -69
- package/src/forms/CheckboxGroup.tsx +76 -29
- package/src/forms/Combobox.tsx +172 -28
- package/src/forms/DatePicker.tsx +385 -81
- package/src/forms/FileUpload.tsx +51 -7
- package/src/forms/FormField.tsx +115 -115
- package/src/forms/Input.tsx +151 -151
- package/src/forms/NumberInput.tsx +34 -20
- package/src/forms/PasswordInput.tsx +35 -35
- package/src/forms/PinInput.tsx +139 -125
- package/src/forms/Radio.tsx +142 -142
- package/src/forms/RangeSlider.tsx +237 -111
- package/src/forms/SearchInput.tsx +34 -34
- package/src/forms/Select.tsx +103 -103
- package/src/forms/Slider.tsx +85 -85
- package/src/forms/Textarea.tsx +114 -114
- package/src/forms/tokens.css +6 -6
- package/src/glass/GlassButton.tsx +59 -59
- package/src/glass/GlassCard.tsx +71 -71
- package/src/glass/index.ts +3 -3
- package/src/glass/tokens.css +81 -81
- package/src/index.ts +26 -26
- package/src/layout/AspectRatio.tsx +39 -39
- package/src/layout/Container.tsx +53 -53
- package/src/layout/Divider.tsx +61 -61
- package/src/layout/Flex.tsx +79 -79
- package/src/layout/Grid.tsx +124 -124
- package/src/layout/PageLayout.tsx +59 -59
- package/src/layout/Section.tsx +47 -47
- package/src/layout/Spacer.tsx +30 -30
- package/src/layout/Stack.tsx +88 -88
- package/src/layout/tokens.css +8 -8
- package/src/navigation/Breadcrumb.tsx +73 -73
- package/src/navigation/CommandPalette.tsx +142 -142
- package/src/navigation/DropdownMenu.tsx +244 -160
- package/src/navigation/Pagination.tsx +114 -114
- package/src/navigation/Stepper.tsx +93 -93
- package/src/navigation/Tabs.tsx +265 -133
- package/src/navigation/index.ts +6 -6
- package/src/navigation/tokens.css +4 -4
- package/src/overlays/AlertDialog.tsx +63 -54
- package/src/overlays/BottomSheet.tsx +30 -10
- package/src/overlays/ContextMenu.tsx +51 -9
- package/src/overlays/Drawer.tsx +160 -145
- package/src/overlays/FocusTrap.tsx +122 -49
- package/src/overlays/Modal.tsx +296 -219
- package/src/overlays/Popover.tsx +55 -25
- package/src/overlays/Portal.tsx +23 -23
- package/src/overlays/Toast.tsx +175 -137
- package/src/overlays/Tooltip.tsx +125 -100
- package/src/overlays/tokens.css +8 -8
- package/src/registry/components.ts +1368 -0
- package/src/styles/components.css +39 -0
- package/src/styles/index.css +2 -93
- package/src/styles/reset.css +45 -0
- package/src/styles/tokens.css +11 -2
- package/src/typography/Blockquote.tsx +32 -32
- package/src/typography/Code.tsx +89 -89
- package/src/typography/Heading.tsx +56 -56
- package/src/typography/Kbd.tsx +25 -25
- package/src/typography/Label.tsx +47 -47
- package/src/typography/Link.tsx +56 -56
- package/src/typography/Text.tsx +62 -62
- package/src/typography/index.ts +7 -7
- package/src/typography/tokens.css +5 -5
- package/src/utils/cn.ts +6 -6
- package/src/utils/scroll-lock.ts +19 -0
- package/src/utils/useControllableState.ts +54 -0
- package/ui-contract.json +192 -35
- package/dist/chunk-2OXZK6IP.js.map +0 -1
- package/dist/chunk-4S6L5V7X.js.map +0 -1
- package/dist/chunk-5FZ6ZFII.js.map +0 -1
- package/dist/chunk-66VWUJEI.js.map +0 -1
- package/dist/chunk-A7P3O5Z5.js.map +0 -1
- package/dist/chunk-ATYGDTMK.js.map +0 -1
- package/dist/chunk-C4SUXMI3.js.map +0 -1
- package/dist/chunk-EU2LPUYP.js.map +0 -1
- package/dist/chunk-F5Q7BTMH.js.map +0 -1
- package/dist/chunk-GXI2H5XA.js.map +0 -1
- package/dist/chunk-H3LCNPMC.js +0 -101
- package/dist/chunk-H3LCNPMC.js.map +0 -1
- package/dist/chunk-IABPUCAQ.js.map +0 -1
- package/dist/chunk-KJRBRTQE.js.map +0 -1
- package/dist/chunk-LBUBXUYQ.js +0 -40
- package/dist/chunk-LBUBXUYQ.js.map +0 -1
- package/dist/chunk-LWFRUJRU.js.map +0 -1
- package/dist/chunk-NFVONJUK.js.map +0 -1
- package/dist/chunk-PF4ZHGVW.js.map +0 -1
- package/dist/chunk-WAFHC4DQ.js.map +0 -1
- package/dist/chunk-X76KBH4Y.js +0 -71
- package/dist/chunk-X76KBH4Y.js.map +0 -1
package/src/forms/Combobox.tsx
CHANGED
|
@@ -23,6 +23,9 @@ export interface ComboboxProps {
|
|
|
23
23
|
disabled?: boolean;
|
|
24
24
|
clearable?: boolean;
|
|
25
25
|
className?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
id?: string;
|
|
28
|
+
'aria-label'?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
@@ -38,16 +41,27 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
38
41
|
disabled = false,
|
|
39
42
|
clearable = true,
|
|
40
43
|
className,
|
|
44
|
+
name,
|
|
45
|
+
id: customId,
|
|
46
|
+
'aria-label': ariaLabel = 'Select option',
|
|
41
47
|
},
|
|
42
48
|
ref
|
|
43
49
|
) => {
|
|
50
|
+
const baseId = useId();
|
|
51
|
+
const comboId = customId || `combobox-${baseId}`;
|
|
52
|
+
const listboxId = `listbox-${baseId}`;
|
|
53
|
+
|
|
44
54
|
const [internalVal, setInternalVal] = useState(defaultValue);
|
|
45
|
-
const
|
|
55
|
+
const isControlled = controlledVal !== undefined;
|
|
56
|
+
const selectedValue = isControlled ? controlledVal : internalVal;
|
|
46
57
|
|
|
47
58
|
const [search, setSearch] = useState('');
|
|
48
59
|
const [isOpen, setIsOpen] = useState(false);
|
|
49
60
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
50
61
|
|
|
62
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
63
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
64
|
+
|
|
51
65
|
const filteredOptions = options.filter(
|
|
52
66
|
(opt) =>
|
|
53
67
|
opt.label.toLowerCase().includes(search.toLowerCase()) ||
|
|
@@ -57,22 +71,35 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
57
71
|
|
|
58
72
|
const selectedOption = options.find((opt) => opt.value === selectedValue);
|
|
59
73
|
|
|
74
|
+
// Keep highlight index valid
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (filteredOptions.length > 0) {
|
|
77
|
+
setHighlightIndex((prev) => Math.min(prev, filteredOptions.length - 1));
|
|
78
|
+
}
|
|
79
|
+
}, [filteredOptions.length]);
|
|
80
|
+
|
|
60
81
|
const handleSelect = (val: string) => {
|
|
61
|
-
|
|
82
|
+
if (!isControlled) {
|
|
83
|
+
setInternalVal(val);
|
|
84
|
+
}
|
|
62
85
|
onChange?.(val);
|
|
63
86
|
setIsOpen(false);
|
|
64
87
|
setSearch('');
|
|
88
|
+
triggerRef.current?.focus();
|
|
65
89
|
};
|
|
66
90
|
|
|
67
91
|
const handleClear = (e: React.MouseEvent) => {
|
|
68
92
|
e.stopPropagation();
|
|
69
|
-
|
|
93
|
+
if (!isControlled) {
|
|
94
|
+
setInternalVal('');
|
|
95
|
+
}
|
|
70
96
|
onChange?.('');
|
|
71
97
|
};
|
|
72
98
|
|
|
73
99
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
74
100
|
if (!isOpen) {
|
|
75
|
-
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
|
101
|
+
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
|
102
|
+
e.preventDefault();
|
|
76
103
|
setIsOpen(true);
|
|
77
104
|
}
|
|
78
105
|
return;
|
|
@@ -80,36 +107,78 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
80
107
|
|
|
81
108
|
if (e.key === 'ArrowDown') {
|
|
82
109
|
e.preventDefault();
|
|
83
|
-
|
|
110
|
+
let next = highlightIndex + 1;
|
|
111
|
+
while (next < filteredOptions.length && filteredOptions[next]?.disabled) {
|
|
112
|
+
next++;
|
|
113
|
+
}
|
|
114
|
+
if (next < filteredOptions.length) {
|
|
115
|
+
setHighlightIndex(next);
|
|
116
|
+
}
|
|
84
117
|
} else if (e.key === 'ArrowUp') {
|
|
85
118
|
e.preventDefault();
|
|
86
|
-
|
|
119
|
+
let prev = highlightIndex - 1;
|
|
120
|
+
while (prev >= 0 && filteredOptions[prev]?.disabled) {
|
|
121
|
+
prev--;
|
|
122
|
+
}
|
|
123
|
+
if (prev >= 0) {
|
|
124
|
+
setHighlightIndex(prev);
|
|
125
|
+
}
|
|
126
|
+
} else if (e.key === 'Home') {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
let first = 0;
|
|
129
|
+
while (first < filteredOptions.length && filteredOptions[first]?.disabled) {
|
|
130
|
+
first++;
|
|
131
|
+
}
|
|
132
|
+
setHighlightIndex(first);
|
|
133
|
+
} else if (e.key === 'End') {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
let last = filteredOptions.length - 1;
|
|
136
|
+
while (last >= 0 && filteredOptions[last]?.disabled) {
|
|
137
|
+
last--;
|
|
138
|
+
}
|
|
139
|
+
setHighlightIndex(last);
|
|
87
140
|
} else if (e.key === 'Enter') {
|
|
88
141
|
e.preventDefault();
|
|
89
|
-
|
|
90
|
-
|
|
142
|
+
const highlighted = filteredOptions[highlightIndex];
|
|
143
|
+
if (highlighted && !highlighted.disabled) {
|
|
144
|
+
handleSelect(highlighted.value);
|
|
91
145
|
}
|
|
92
146
|
} else if (e.key === 'Escape') {
|
|
147
|
+
e.preventDefault();
|
|
93
148
|
setIsOpen(false);
|
|
149
|
+
triggerRef.current?.focus();
|
|
94
150
|
}
|
|
95
151
|
};
|
|
96
152
|
|
|
153
|
+
const activeDescendantId =
|
|
154
|
+
filteredOptions[highlightIndex]
|
|
155
|
+
? `combobox-opt-${baseId}-${filteredOptions[highlightIndex].value}`
|
|
156
|
+
: undefined;
|
|
157
|
+
|
|
97
158
|
return (
|
|
98
159
|
<div ref={ref} className={cn('relative w-full', className)}>
|
|
160
|
+
{name && <input type="hidden" name={name} value={selectedValue} />}
|
|
99
161
|
<Popover
|
|
100
162
|
isOpen={isOpen}
|
|
101
163
|
onClose={() => setIsOpen(false)}
|
|
102
164
|
trigger={
|
|
103
165
|
<button
|
|
166
|
+
ref={triggerRef}
|
|
167
|
+
id={comboId}
|
|
104
168
|
type="button"
|
|
105
169
|
disabled={disabled}
|
|
170
|
+
role="combobox"
|
|
171
|
+
aria-expanded={isOpen}
|
|
172
|
+
aria-haspopup="listbox"
|
|
173
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
174
|
+
aria-label={ariaLabel}
|
|
106
175
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
107
176
|
onKeyDown={handleKeyDown}
|
|
108
177
|
className={cn(
|
|
109
|
-
'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm transition-all shadow-inner cursor-pointer',
|
|
178
|
+
'w-full h-10 px-3.5 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] text-left flex items-center justify-between text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
|
|
110
179
|
disabled
|
|
111
180
|
? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
|
|
112
|
-
: 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]
|
|
181
|
+
: 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
|
|
113
182
|
)}
|
|
114
183
|
>
|
|
115
184
|
<div className="flex items-center gap-2.5 truncate">
|
|
@@ -123,7 +192,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
123
192
|
{clearable && selectedValue && !disabled && (
|
|
124
193
|
<span
|
|
125
194
|
onClick={handleClear}
|
|
126
|
-
className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
|
|
195
|
+
className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
|
|
127
196
|
>
|
|
128
197
|
<X className="w-3.5 h-3.5" />
|
|
129
198
|
</span>
|
|
@@ -137,6 +206,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
137
206
|
<div className="relative flex items-center">
|
|
138
207
|
<Search className="absolute left-2.5 w-3.5 h-3.5 text-[var(--ui-text-muted)] pointer-events-none" />
|
|
139
208
|
<input
|
|
209
|
+
ref={searchInputRef}
|
|
140
210
|
type="text"
|
|
141
211
|
autoFocus
|
|
142
212
|
placeholder={searchPlaceholder}
|
|
@@ -145,27 +215,36 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
|
|
|
145
215
|
setSearch(e.target.value);
|
|
146
216
|
setHighlightIndex(0);
|
|
147
217
|
}}
|
|
218
|
+
onKeyDown={handleKeyDown}
|
|
219
|
+
role="combobox"
|
|
220
|
+
aria-autocomplete="list"
|
|
221
|
+
aria-expanded="true"
|
|
222
|
+
aria-controls={listboxId}
|
|
223
|
+
aria-activedescendant={activeDescendantId}
|
|
148
224
|
className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-secondary)] border border-[var(--ui-border-subtle)] focus:outline-none focus:border-[var(--ui-primary)] text-[var(--ui-text-primary)] placeholder:text-[var(--ui-text-muted)]"
|
|
149
225
|
/>
|
|
150
226
|
</div>
|
|
151
227
|
|
|
152
|
-
<div className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
|
|
228
|
+
<div id={listboxId} className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
|
|
153
229
|
{filteredOptions.length === 0 ? (
|
|
154
230
|
<div className="py-4 text-center text-xs text-[var(--ui-text-muted)]">{emptyText}</div>
|
|
155
231
|
) : (
|
|
156
232
|
filteredOptions.map((opt, idx) => {
|
|
157
233
|
const isSelected = opt.value === selectedValue;
|
|
158
234
|
const isHighlighted = idx === highlightIndex;
|
|
235
|
+
const optId = `combobox-opt-${baseId}-${opt.value}`;
|
|
159
236
|
|
|
160
237
|
return (
|
|
161
238
|
<div
|
|
162
239
|
key={opt.value}
|
|
240
|
+
id={optId}
|
|
163
241
|
role="option"
|
|
164
242
|
aria-selected={isSelected}
|
|
243
|
+
aria-disabled={opt.disabled}
|
|
165
244
|
onClick={() => !opt.disabled && handleSelect(opt.value)}
|
|
166
|
-
onMouseEnter={() => setHighlightIndex(idx)}
|
|
245
|
+
onMouseEnter={() => !opt.disabled && setHighlightIndex(idx)}
|
|
167
246
|
className={cn(
|
|
168
|
-
'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors',
|
|
247
|
+
'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors select-none',
|
|
169
248
|
opt.disabled && 'opacity-40 cursor-not-allowed pointer-events-none',
|
|
170
249
|
isSelected
|
|
171
250
|
? 'bg-[var(--ui-primary)] text-white font-semibold shadow-xs'
|
|
@@ -231,10 +310,12 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
|
231
310
|
ref
|
|
232
311
|
) => {
|
|
233
312
|
const [internalVal, setInternalVal] = useState<string[]>(defaultValue);
|
|
234
|
-
const
|
|
313
|
+
const isControlled = controlledVal !== undefined;
|
|
314
|
+
const selectedValues = isControlled ? controlledVal : internalVal;
|
|
235
315
|
|
|
236
316
|
const [search, setSearch] = useState('');
|
|
237
317
|
const [isOpen, setIsOpen] = useState(false);
|
|
318
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
238
319
|
|
|
239
320
|
const filteredOptions = options.filter(
|
|
240
321
|
(opt) =>
|
|
@@ -243,26 +324,68 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
|
243
324
|
);
|
|
244
325
|
|
|
245
326
|
const toggleOption = (val: string) => {
|
|
327
|
+
const opt = options.find((o) => o.value === val);
|
|
328
|
+
if (opt?.disabled) return;
|
|
329
|
+
|
|
246
330
|
const next = selectedValues.includes(val)
|
|
247
331
|
? selectedValues.filter((v) => v !== val)
|
|
248
332
|
: [...selectedValues, val];
|
|
249
|
-
|
|
333
|
+
if (!isControlled) {
|
|
334
|
+
setInternalVal(next);
|
|
335
|
+
}
|
|
250
336
|
onChange?.(next);
|
|
251
337
|
};
|
|
252
338
|
|
|
253
339
|
const removeTag = (val: string, e?: React.MouseEvent) => {
|
|
254
340
|
e?.stopPropagation?.();
|
|
255
341
|
const next = selectedValues.filter((v) => v !== val);
|
|
256
|
-
|
|
342
|
+
if (!isControlled) {
|
|
343
|
+
setInternalVal(next);
|
|
344
|
+
}
|
|
257
345
|
onChange?.(next);
|
|
258
346
|
};
|
|
259
347
|
|
|
260
348
|
const clearAll = (e?: React.MouseEvent) => {
|
|
261
349
|
e?.stopPropagation?.();
|
|
262
|
-
|
|
350
|
+
if (!isControlled) {
|
|
351
|
+
setInternalVal([]);
|
|
352
|
+
}
|
|
263
353
|
onChange?.([]);
|
|
264
354
|
};
|
|
265
355
|
|
|
356
|
+
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
357
|
+
if (filteredOptions.length === 0) return;
|
|
358
|
+
|
|
359
|
+
if (e.key === 'ArrowDown') {
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
setHighlightedIndex((prev) => {
|
|
362
|
+
let next = (prev + 1) % filteredOptions.length;
|
|
363
|
+
while (filteredOptions[next]?.disabled && next !== prev) {
|
|
364
|
+
next = (next + 1) % filteredOptions.length;
|
|
365
|
+
}
|
|
366
|
+
return next;
|
|
367
|
+
});
|
|
368
|
+
} else if (e.key === 'ArrowUp') {
|
|
369
|
+
e.preventDefault();
|
|
370
|
+
setHighlightedIndex((prev) => {
|
|
371
|
+
let next = (prev - 1 + filteredOptions.length) % filteredOptions.length;
|
|
372
|
+
while (filteredOptions[next]?.disabled && next !== prev) {
|
|
373
|
+
next = (next - 1 + filteredOptions.length) % filteredOptions.length;
|
|
374
|
+
}
|
|
375
|
+
return next;
|
|
376
|
+
});
|
|
377
|
+
} else if (e.key === 'Enter' || e.key === ' ') {
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
const current = filteredOptions[highlightedIndex];
|
|
380
|
+
if (current && !current.disabled) {
|
|
381
|
+
toggleOption(current.value);
|
|
382
|
+
}
|
|
383
|
+
} else if (e.key === 'Escape') {
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
setIsOpen(false);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
266
389
|
return (
|
|
267
390
|
<div ref={ref} className={cn('relative w-full', className)}>
|
|
268
391
|
<Popover
|
|
@@ -270,9 +393,19 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
|
270
393
|
onClose={() => setIsOpen(false)}
|
|
271
394
|
trigger={
|
|
272
395
|
<div
|
|
396
|
+
tabIndex={disabled ? -1 : 0}
|
|
397
|
+
role="combobox"
|
|
398
|
+
aria-expanded={isOpen}
|
|
399
|
+
aria-haspopup="listbox"
|
|
273
400
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
401
|
+
onKeyDown={(e) => {
|
|
402
|
+
if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !disabled) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
setIsOpen(true);
|
|
405
|
+
}
|
|
406
|
+
}}
|
|
274
407
|
className={cn(
|
|
275
|
-
'min-h-10 py-1.5 px-3 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] flex flex-wrap items-center justify-between gap-1.5 text-sm transition-all shadow-inner cursor-pointer',
|
|
408
|
+
'min-h-10 py-1.5 px-3 rounded-[var(--ui-radius-md)] border bg-[var(--ui-bg-surface)] flex flex-wrap items-center justify-between gap-1.5 text-sm transition-all shadow-inner cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-primary)]',
|
|
276
409
|
disabled
|
|
277
410
|
? 'opacity-50 cursor-not-allowed border-[var(--ui-border-subtle)]'
|
|
278
411
|
: 'border-[var(--ui-border-default)] hover:border-[var(--ui-border-strong)]'
|
|
@@ -309,7 +442,7 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
|
309
442
|
{selectedValues.length > 0 && !disabled && (
|
|
310
443
|
<span
|
|
311
444
|
onClick={clearAll}
|
|
312
|
-
className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors"
|
|
445
|
+
className="p-1 rounded-full hover:bg-[var(--ui-bg-tertiary)] hover:text-[var(--ui-text-primary)] transition-colors cursor-pointer"
|
|
313
446
|
>
|
|
314
447
|
<X className="w-3.5 h-3.5" />
|
|
315
448
|
</span>
|
|
@@ -327,26 +460,37 @@ export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
|
327
460
|
autoFocus
|
|
328
461
|
placeholder={searchPlaceholder}
|
|
329
462
|
value={search}
|
|
330
|
-
onChange={(e) =>
|
|
463
|
+
onChange={(e) => {
|
|
464
|
+
setSearch(e.target.value);
|
|
465
|
+
setHighlightedIndex(0);
|
|
466
|
+
}}
|
|
467
|
+
onKeyDown={handleSearchKeyDown}
|
|
331
468
|
className="w-full h-8 pl-8 pr-3 text-xs rounded-xl bg-[var(--ui-bg-secondary)] border border-[var(--ui-border-subtle)] focus:outline-none focus:border-[var(--ui-primary)] text-[var(--ui-text-primary)]"
|
|
332
469
|
/>
|
|
333
470
|
</div>
|
|
334
471
|
|
|
335
|
-
<div className="max-h-56 overflow-y-auto space-y-0.5">
|
|
472
|
+
<div className="max-h-56 overflow-y-auto space-y-0.5" role="listbox">
|
|
336
473
|
{filteredOptions.length === 0 ? (
|
|
337
474
|
<div className="py-4 text-center text-xs text-[var(--ui-text-muted)]">No options found.</div>
|
|
338
475
|
) : (
|
|
339
|
-
filteredOptions.map((opt) => {
|
|
476
|
+
filteredOptions.map((opt, index) => {
|
|
340
477
|
const isChecked = selectedValues.includes(opt.value);
|
|
478
|
+
const isHighlighted = highlightedIndex === index;
|
|
479
|
+
|
|
341
480
|
return (
|
|
342
481
|
<div
|
|
343
482
|
key={opt.value}
|
|
344
|
-
|
|
483
|
+
role="option"
|
|
484
|
+
aria-selected={isChecked}
|
|
485
|
+
aria-disabled={opt.disabled}
|
|
486
|
+
onClick={() => !opt.disabled && toggleOption(opt.value)}
|
|
345
487
|
className={cn(
|
|
346
|
-
'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium
|
|
347
|
-
|
|
348
|
-
? '
|
|
349
|
-
: '
|
|
488
|
+
'flex items-center justify-between px-3 py-2 rounded-xl text-xs font-medium transition-colors select-none',
|
|
489
|
+
opt.disabled
|
|
490
|
+
? 'opacity-40 cursor-not-allowed text-[var(--ui-text-muted)]'
|
|
491
|
+
: 'cursor-pointer',
|
|
492
|
+
isChecked && 'text-[var(--ui-primary)] font-semibold',
|
|
493
|
+
isHighlighted && !opt.disabled && 'bg-[var(--ui-primary)]/15'
|
|
350
494
|
)}
|
|
351
495
|
>
|
|
352
496
|
<div className="flex items-center gap-2 truncate">
|