Skip to content

Commit 95df5c1

Browse files
mfrachetclaude
andauthored
refactor(playground-ui): migrate Combobox to @base-ui/react (#12377)
Refactored the Combobox component to use `@base-ui/react` instead of a custom implementation built on Radix UI Popover. This removes ~70 lines of manual keyboard navigation and state management code since Base UI handles it automatically. The props interface and styling remain identical. **Before (custom Popover implementation):** ```tsx const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [highlightedIndex, setHighlightedIndex] = useState(0); // ...keyboard handlers, scroll management, etc. ``` **After (Base UI):** ```tsx <BaseCombobox.Root items={options} value={selectedOption} onValueChange={handleSelect}> <BaseCombobox.Trigger>...</BaseCombobox.Trigger> <BaseCombobox.Portal> <BaseCombobox.Popup> <BaseCombobox.Input /> <BaseCombobox.List>{(option) => <BaseCombobox.Item />}</BaseCombobox.List> </BaseCombobox.Popup> </BaseCombobox.Portal> </BaseCombobox.Root> ``` Tested all 8 Storybook stories - dropdown, search filtering, selection with checkmark, and disabled state all work correctly. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * The Combobox component has been internally refactored to simplify implementation while preserving the same appearance, props, and keyboard/accessibility behavior. Existing integrations remain fully compatible and no user-facing changes are expected. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8aff54d commit 95df5c1

File tree

5 files changed

+133
-167
lines changed

5 files changed

+133
-167
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@mastra/playground-ui": minor
3+
---
4+
5+
Refactored Combobox component to use @base-ui/react instead of custom Radix UI Popover implementation. This removes ~70 lines of manual keyboard navigation code while preserving the same props interface and styling.

packages/playground-ui/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@
5757
"license": "Apache-2.0",
5858
"dependencies": {
5959
"@assistant-ui/react": "^0.11.47",
60-
"cmdk": "^1.1.1",
6160
"@assistant-ui/react-markdown": "^0.11.6",
6261
"@assistant-ui/react-syntax-highlighter": "^0.11.6",
6362
"@assistant-ui/react-ui": "^0.2.1",
6463
"@autoform/core": "^3.0.0",
6564
"@autoform/react": "^4.0.0",
6665
"@autoform/zod": "^4.0.0",
66+
"@base-ui/react": "^1.1.0",
6767
"@codemirror/lang-javascript": "^6.2.2",
6868
"@codemirror/lang-json": "^6.0.2",
6969
"@codemirror/lang-markdown": "^6.5.0",
@@ -97,6 +97,7 @@
9797
"@uiw/codemirror-theme-github": "^4.25.3",
9898
"@uiw/react-codemirror": "^4.23.14",
9999
"@xyflow/react": "^12.9.3",
100+
"cmdk": "^1.1.1",
100101
"date-fns": "^4.1.0",
101102
"prettier": "^3.6.2",
102103
"prism-react-renderer": "^2.4.1",
Lines changed: 66 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Button as DSButton } from '@/ds/components/Button/Button';
2-
import { Popover, PopoverContent, PopoverTrigger } from '@/ds/components/Popover';
1+
import { Combobox as BaseCombobox } from '@base-ui/react/combobox';
2+
import { buttonVariants } from '@/ds/components/Button/Button';
33
import { cn } from '@/lib/utils';
44
import { Check, ChevronsUpDown, Search } from 'lucide-react';
55
import * as React from 'react';
6-
import { type FormElementSize, formElementFocus } from '@/ds/primitives/form-element';
6+
import { type FormElementSize } from '@/ds/primitives/form-element';
77
import { transitions } from '@/ds/primitives/transitions';
88

99
export type ComboboxOption = {
@@ -36,173 +36,74 @@ export function Combobox({
3636
variant = 'default',
3737
size = 'md',
3838
}: ComboboxProps) {
39-
const [open, setOpen] = React.useState(false);
40-
const [search, setSearch] = React.useState('');
41-
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
42-
const triggerRef = React.useRef<HTMLButtonElement>(null);
43-
const [triggerWidth, setTriggerWidth] = React.useState<number | undefined>(undefined);
44-
const inputRef = React.useRef<HTMLInputElement>(null);
45-
const listRef = React.useRef<HTMLDivElement>(null);
39+
const selectedOption = options.find(option => option.value === value) ?? null;
4640

47-
const selectedOption = options.find(option => option.value === value);
48-
49-
const handleSelect = (optionValue: string) => {
50-
onValueChange?.(optionValue);
51-
setOpen(false);
52-
setSearch('');
53-
setHighlightedIndex(0);
54-
};
55-
56-
React.useEffect(() => {
57-
if (triggerRef.current) {
58-
setTriggerWidth(triggerRef.current.offsetWidth);
59-
}
60-
}, [open]);
61-
62-
React.useEffect(() => {
63-
if (open && inputRef.current) {
64-
inputRef.current.focus();
65-
}
66-
}, [open]);
67-
68-
const filteredOptions = React.useMemo(() => {
69-
if (!search) return options;
70-
return options.filter(option => option.label.toLowerCase().includes(search.toLowerCase()));
71-
}, [options, search]);
72-
73-
React.useEffect(() => {
74-
setHighlightedIndex(0);
75-
}, [filteredOptions]);
76-
77-
const handleKeyDown = (e: React.KeyboardEvent) => {
78-
if (!open) return;
79-
80-
switch (e.key) {
81-
case 'ArrowDown':
82-
e.preventDefault();
83-
setHighlightedIndex(prev => (prev < filteredOptions.length - 1 ? prev + 1 : prev));
84-
break;
85-
case 'ArrowUp':
86-
e.preventDefault();
87-
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : prev));
88-
break;
89-
case 'Enter':
90-
e.preventDefault();
91-
if (filteredOptions[highlightedIndex]) {
92-
handleSelect(filteredOptions[highlightedIndex].value);
93-
}
94-
break;
95-
case 'Escape':
96-
e.preventDefault();
97-
setOpen(false);
98-
setSearch('');
99-
break;
100-
case 'Home':
101-
e.preventDefault();
102-
setHighlightedIndex(0);
103-
break;
104-
case 'End':
105-
e.preventDefault();
106-
setHighlightedIndex(filteredOptions.length - 1);
107-
break;
41+
const handleSelect = (item: ComboboxOption | null) => {
42+
if (item) {
43+
onValueChange?.(item.value);
10844
}
10945
};
11046

111-
React.useEffect(() => {
112-
if (listRef.current && highlightedIndex >= 0) {
113-
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
114-
if (highlightedElement) {
115-
highlightedElement.scrollIntoView({ block: 'nearest' });
116-
}
117-
}
118-
}, [highlightedIndex]);
119-
12047
return (
121-
<Popover open={open} onOpenChange={setOpen}>
122-
<PopoverTrigger asChild>
123-
<DSButton
124-
ref={triggerRef}
125-
role="combobox"
126-
aria-expanded={open}
127-
variant={variant}
128-
size={size}
129-
className={cn('w-full justify-between', className)}
130-
disabled={disabled}
131-
>
132-
<span className="truncate">{selectedOption ? selectedOption.label : placeholder}</span>
133-
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
134-
</DSButton>
135-
</PopoverTrigger>
136-
<PopoverContent className="p-0 w-fit" align="start">
137-
<div
138-
className={cn(
139-
'flex h-full w-full flex-col overflow-hidden rounded-md bg-surface3 text-neutral5',
140-
'shadow-elevated',
141-
'animate-in fade-in-0 zoom-in-95 duration-150',
142-
)}
143-
>
144-
<div className={cn('flex items-center border-b border-border1 px-3 py-2', transitions.colors)}>
145-
<Search className={cn('mr-2 h-4 w-4 shrink-0 text-neutral3', transitions.colors)} />
146-
<input
147-
ref={inputRef}
148-
className={cn(
149-
'flex h-8 w-full rounded-md bg-transparent py-1 text-sm',
150-
'placeholder:text-neutral3 disabled:cursor-not-allowed disabled:opacity-50',
151-
'outline-none',
152-
transitions.colors,
153-
)}
154-
placeholder={searchPlaceholder}
155-
value={search}
156-
onChange={e => setSearch(e.target.value)}
157-
onKeyDown={handleKeyDown}
158-
role="combobox"
159-
aria-autocomplete="list"
160-
aria-controls="combobox-options"
161-
aria-expanded={open}
162-
/>
163-
</div>
164-
<div
165-
ref={listRef}
166-
id="combobox-options"
167-
role="listbox"
168-
className="max-h-dropdown-max-height overflow-y-auto overflow-x-hidden p-1"
169-
>
170-
{filteredOptions.length === 0 ? (
171-
<div className="py-6 text-center text-sm text-neutral3">{emptyText}</div>
172-
) : (
173-
filteredOptions.map((option, index) => {
174-
const isSelected = value === option.value;
175-
const isHighlighted = index === highlightedIndex;
176-
return (
177-
<div
178-
key={option.value}
179-
role="option"
180-
aria-selected={isSelected}
181-
className={cn(
182-
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm',
183-
transitions.colors,
184-
'hover:bg-surface5 hover:text-neutral5',
185-
isHighlighted && 'bg-surface5 text-neutral5',
186-
isSelected && !isHighlighted && 'bg-accent1Dark text-accent1',
187-
)}
188-
onClick={() => handleSelect(option.value)}
189-
onMouseEnter={() => setHighlightedIndex(index)}
190-
>
191-
<Check
192-
className={cn(
193-
'mr-2 h-4 w-4',
194-
transitions.opacity,
195-
isSelected ? 'opacity-100 text-accent1' : 'opacity-0',
196-
)}
197-
/>
198-
{option.label}
199-
</div>
200-
);
201-
})
48+
<BaseCombobox.Root items={options} value={selectedOption} onValueChange={handleSelect} disabled={disabled}>
49+
<BaseCombobox.Trigger className={cn(buttonVariants({ variant, size }), 'w-full justify-between', className)}>
50+
<span className="truncate">
51+
<BaseCombobox.Value placeholder={placeholder} />
52+
</span>
53+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
54+
</BaseCombobox.Trigger>
55+
56+
<BaseCombobox.Portal>
57+
<BaseCombobox.Positioner align="start" sideOffset={4}>
58+
<BaseCombobox.Popup
59+
className={cn(
60+
'min-w-[var(--anchor-width)] w-max rounded-md bg-surface3 text-neutral5',
61+
'shadow-elevated',
62+
'origin-[var(--transform-origin)]',
63+
'transition-[transform,scale,opacity] duration-150 ease-out',
64+
'data-[starting-style]:scale-95 data-[starting-style]:opacity-0',
65+
'data-[ending-style]:scale-95 data-[ending-style]:opacity-0',
20266
)}
203-
</div>
204-
</div>
205-
</PopoverContent>
206-
</Popover>
67+
>
68+
<div className={cn('flex items-center border-b border-border1 px-3 py-2', transitions.colors)}>
69+
<Search className={cn('mr-2 h-4 w-4 shrink-0 text-neutral3', transitions.colors)} />
70+
<BaseCombobox.Input
71+
className={cn(
72+
'flex h-8 w-full rounded-md bg-transparent py-1 text-sm',
73+
'placeholder:text-neutral3 disabled:cursor-not-allowed disabled:opacity-50',
74+
'outline-none',
75+
transitions.colors,
76+
)}
77+
placeholder={searchPlaceholder}
78+
/>
79+
</div>
80+
<BaseCombobox.Empty className="[&:not(:empty)]:block hidden py-6 text-center text-sm text-neutral3">
81+
{emptyText}
82+
</BaseCombobox.Empty>
83+
<BaseCombobox.List className="max-h-dropdown-max-height overflow-y-auto overflow-x-hidden p-1">
84+
{(option: ComboboxOption) => (
85+
<BaseCombobox.Item
86+
key={option.value}
87+
value={option}
88+
className={cn(
89+
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm',
90+
transitions.colors,
91+
'data-[highlighted]:bg-surface5 data-[highlighted]:text-neutral5',
92+
'data-[selected]:bg-accent1Dark data-[selected]:text-accent1',
93+
)}
94+
>
95+
<span className="mr-2 flex h-4 w-4 shrink-0 items-center justify-center">
96+
<BaseCombobox.ItemIndicator>
97+
<Check className={cn('h-4 w-4 text-accent1', transitions.opacity)} />
98+
</BaseCombobox.ItemIndicator>
99+
</span>
100+
<span className="whitespace-nowrap">{option.label}</span>
101+
</BaseCombobox.Item>
102+
)}
103+
</BaseCombobox.List>
104+
</BaseCombobox.Popup>
105+
</BaseCombobox.Positioner>
106+
</BaseCombobox.Portal>
107+
</BaseCombobox.Root>
207108
);
208109
}

packages/playground/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const paths: LinkComponentProviderProps['paths'] = {
7676

7777
const RootLayout = () => {
7878
const navigate = useNavigate();
79-
const frameworkNavigate = (path: string) => navigate(path);
79+
const frameworkNavigate = (path: string) => navigate(path, { viewTransition: true });
8080

8181
return (
8282
<LinkComponentProvider Link={Link} navigate={frameworkNavigate} paths={paths}>

pnpm-lock.yaml

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)