mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
## 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>
426 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|