Files
supabase/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx
Danny WhiteandClaude Opus 5 30ab816ff4 feat(studio): make the Connect framework and client selectors searchable (#50072)
## What kind of change does this PR introduce?

Feature.

## What is the current behavior?

The Framework and Client selectors in the Connect sheet are plain
selects. Neither is scannable at its current length, and Client is the
worse of the two at 19 options.

## What is the new behavior?

Both are searchable comboboxes. Each keeps its selection, filters as you
type, matches on the underlying key as well as the label so `nextjs`
finds `Next.js`, and announces its empty state to screen readers.

| Before | After |
| --- | --- |
| <img width="1182" height="1250" alt="CleanShot 2026-09-07 at 14 47
12@2x"
src="https://github.com/user-attachments/assets/6243c549-03cc-41bf-8b3c-a186ca0e93b5"
/> | <img width="1178" height="1162" alt="CleanShot 2026-09-07 at 14 46
42@2x"
src="https://github.com/user-attachments/assets/d7ca5e72-3f8b-48e5-b20f-84382e4e4fd7"
/> |

Placeholder, search and empty-state copy now sit on the field definition
in the schema, next to the label, so one combobox component serves both
fields without guessing at plurals.

Client keeps its icons hidden, matching what the select did. The comment
about MCP images being unoptimized still stands, so this is not the PR
to turn them on.

Radix Select brings its own scroll lock, so replacing it with a popover
would have regressed touch scrolling in the sheet. #50103 moved that
guard into `CommandList` and has merged, so this branch now carries the
feature only.

## To test

- Open the Connect sheet on the deploy preview.
- Open the Framework selector, search for `native`, confirm only React
Native remains, select it, and confirm the generated connection
instructions update.
- Search `nextjs` and confirm Next.js matches on its key.
- Switch to the MCP tab and open Client. Search `cur` and confirm Cursor
matches.
- Confirm both lists cap their height and scroll, and that the sheet
behind stays put.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Framework selection now uses a searchable combobox for easier
navigation of long lists.
  * Search results clear automatically when the combobox closes.
  * Long framework lists appear in a contained, scrollable area.

* **Accessibility**
  * Screen readers announce when no frameworks match the search.
* Improved combobox and listbox relationships support assistive
technologies.
* The dropdown opens as a modal layer to keep focus within the selection
experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:35:05 +00:00

426 lines
15 KiB
TypeScript

import { Check, ChevronsUpDown } from 'lucide-react'
import { useState } from 'react'
import {
Button,
cn,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import type { ConnectMode, FieldOption, ResolvedField } from './Connect.types'
import { getOptionMatchScore } from './ConnectConfigSection.utils'
import { ConnectionIcon } from './ConnectionIcon'
import {
ConnectModeButton,
getConnectModeButtonCornerVariants,
getConnectModeEmptySlotClasses,
} from './ConnectModeButton'
interface ConnectConfigSectionProps {
activeFields: ResolvedField[]
state: Record<string, string | boolean | string[]>
onFieldChange: (fieldId: string, value: string | boolean | string[]) => void
getFieldOptions: (fieldId: string) => FieldOption[]
}
export function ConnectConfigSection({
activeFields,
state,
onFieldChange,
getFieldOptions,
}: ConnectConfigSectionProps) {
if (activeFields.length === 0) return null
return (
<div className="flex flex-col gap-y-4">
{activeFields.map((field) => {
const options = getFieldOptions(field.id)
const value = state[field.id]
// Skip fields with no options (or single option that's auto-selected)
// Exception: switch and multi-select fields don't require options
if (field.type !== 'switch' && field.type !== 'multi-select') {
if (options.length === 0) return null
if (options.length === 1) return null
}
switch (field.type) {
case 'combobox':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
name={`connect-${field.id}`}
>
<ConnectCombobox
id={`connect-${field.id}`}
options={options}
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
placeholder={field.combobox?.placeholder ?? 'Select option'}
searchPlaceholder={field.combobox?.searchPlaceholder ?? 'Search...'}
emptyMessage={field.combobox?.emptyMessage ?? 'No results found'}
/*
[Joshen] Omitting MCP icons for now as the images are not optimized (large)
and is causing noticeably latency issues on the browser (even with the existing Connect UI)
*/
showIcons={field.id === 'framework'}
/>
</FormItemLayout>
)
case 'radio-grid':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
>
<RadioGroupStacked
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
className="flex-row gap-3 space-y-0"
>
{options.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={`connect-${field.id}-${option.value}`}
value={option.value}
label=""
className="flex-1 rounded-lg text-left"
>
<div className="flex items-center gap-2">
{option.icon && <ConnectionIcon supportsDarkMode icon={option.icon} />}
<span className="text-sm">{option.label}</span>
</div>
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormItemLayout>
)
case 'radio-list':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
>
<RadioGroupStacked
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
className="min-w-0 w-full"
>
{options.map((option) => (
<RadioGroupStackedItem
key={option.value}
id={`connect-${field.id}-${option.value}`}
value={option.value}
className="min-w-0 w-full text-left"
label={
<span className="flex min-w-0 items-center gap-2">
{option.icon && <ConnectionIcon icon={option.icon} />}
<span className="truncate">{option.label}</span>
</span>
}
description={option.description}
/>
))}
</RadioGroupStacked>
</FormItemLayout>
)
case 'select':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
name={`connect-${field.id}`}
>
<Select
value={String(value ?? '')}
onValueChange={(v) => onFieldChange(field.id, v)}
>
<SelectTrigger
id={`connect-${field.id}`}
size="small"
className="[&>span:first-child]:flex [&>span:first-child]:items-center [&>span:first-child]:gap-x-2"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="[&>span:last-child]:flex [&>span:last-child]:items-center [&>span:last-child]:gap-x-2"
>
{/*
[Joshen] Omitting MCP icons for now as the images are not optimized (large)
and is causing noticeably latency issues on the browser (even with the existing Connect UI)
*/}
{field.id === 'framework' && option.icon && (
<ConnectionIcon icon={option.icon} />
)}
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)
case 'switch':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
name={field.id}
className="[&>div>label>span]:break-keep! [&>div>label>span]:text-balance"
>
<Switch
id={field.id}
checked={Boolean(value)}
onCheckedChange={(v) => onFieldChange(field.id, v)}
/>
</FormItemLayout>
)
case 'multi-select':
return (
<FormItemLayout
key={field.id}
isReactForm={false}
layout="horizontal"
label={field.label}
description={field.description}
name={`connect-${field.id}`}
>
<MultiSelector
values={Array.isArray(value) ? value : []}
onValuesChange={(v) => onFieldChange(field.id, v)}
>
<MultiSelectorTrigger
id={`connect-${field.id}`}
className="w-full"
label="Select features"
badgeLimit="wrap"
showIcon={true}
/>
<MultiSelectorContent>
<MultiSelectorList>
{options.map((option) => (
<MultiSelectorItem
key={option.value}
value={option.value}
className="items-start"
>
<div className="flex flex-col ml-2 gap-y-0.5">
<span className="font-medium">{option.label}</span>
{option.description && (
<span className="text-xs text-foreground-light">
{option.description}
</span>
)}
</div>
</MultiSelectorItem>
))}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
</FormItemLayout>
)
default:
return null
}
})}
</div>
)
}
interface ConnectComboboxProps {
id: string
options: FieldOption[]
value: string
onValueChange: (value: string) => void
placeholder: string
searchPlaceholder: string
emptyMessage: string
showIcons: boolean
}
function ConnectCombobox({
id,
options,
value,
onValueChange,
placeholder,
searchPlaceholder,
emptyMessage,
showIcons,
}: ConnectComboboxProps) {
const [isOpen, setIsOpen] = useState(false)
const [search, setSearch] = useState('')
const [listboxElementId, setListboxElementId] = useState<string>()
const selectedOption = options.find((option) => option.value === value)
const showEmptyStatus =
search.trim().length > 0 &&
!options.some((option) => getOptionMatchScore(option.label, search, [option.value]) > 0)
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open) setSearch('')
}
return (
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={false}>
<PopoverTrigger asChild>
<Button
id={id}
variant="default"
size="small"
role="combobox"
aria-expanded={isOpen}
aria-controls={listboxElementId}
className={cn('w-full justify-between', !selectedOption && 'text-foreground-muted')}
iconRight={<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" strokeWidth={1} />}
>
<span className="flex min-w-0 items-center gap-x-2">
{showIcons && selectedOption?.icon && (
<span aria-hidden="true" className="flex shrink-0">
<ConnectionIcon icon={selectedOption.icon} />
</span>
)}
<span className="truncate">{selectedOption?.label ?? placeholder}</span>
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0" sameWidthAsTrigger>
<Command filter={getOptionMatchScore}>
<CommandInput placeholder={searchPlaceholder} value={search} onValueChange={setSearch} />
<p className="sr-only" role="status" aria-live="polite">
{showEmptyStatus ? emptyMessage : ''}
</p>
<CommandList
className="max-h-72 overscroll-contain"
ref={(node) => {
if (node?.id) setListboxElementId(node.id)
}}
>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
keywords={[option.value]}
onSelect={() => {
onValueChange(option.value)
handleOpenChange(false)
}}
className="gap-x-2"
>
<Check
className={cn(
'h-4 w-4 shrink-0',
option.value === value ? 'opacity-100' : 'opacity-0'
)}
/>
{showIcons && option.icon && (
<span aria-hidden="true" className="flex shrink-0">
<ConnectionIcon icon={option.icon} />
</span>
)}
<span className="truncate">{option.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
interface ModeSelectorProps {
modes: Array<{ id: ConnectMode; label: string; description: string }>
selected: ConnectMode
onChange: (mode: ConnectMode) => void
}
export function ModeSelector({ modes, selected, onChange }: ModeSelectorProps) {
const count = modes.length
// 2-col layout leaves an empty cell when count is odd; hide it once we switch to a single row
const emptySlots = count % 2 === 1 ? 1 : 0
return (
// Container query: 2-col when the sheet is narrow; one equal row when there's room
<div className="@container">
<div
className={cn(
'grid',
'grid-cols-2',
count === 3 && '@[28rem]:grid-cols-3',
count === 4 && '@[30rem]:grid-cols-4',
count === 5 && '@[32rem]:grid-cols-5',
count >= 6 && '@[36rem]:grid-cols-6'
)}
>
{modes.map((mode, index) => (
<ConnectModeButton
key={mode.id}
modeId={mode.id}
label={mode.label}
description={mode.description}
selected={selected === mode.id}
onClick={() => onChange(mode.id)}
{...getConnectModeButtonCornerVariants({ index, count, emptySlots })}
/>
))}
{Array.from({ length: emptySlots }, (_, index) => (
<div
key={`empty-${index}`}
aria-hidden
className={getConnectModeEmptySlotClasses(count)}
/>
))}
</div>
</div>
)
}