feat(ui): align combobox trigger with select styling (#49665)

## What kind of change does this PR introduce?

Bug fix and shared UI primitive.

## What is the current behaviour?

In **Database > Replication > Add destination**, the searchable
**Publication** field uses a double chevron and button styling. It looks
inconsistent beside the **Initial sync** select, which uses a single
chevron and the standard select surface.

The shared multi-select trigger uses the same inconsistent double
chevron. These differences are easy to repeat because searchable
comboboxes do not have a dedicated trigger that shares the select
styling.

## What is the new behaviour?

- Adds `ComboboxTrigger` with the same shared styling as `SelectTrigger`
- Uses a fixed single down chevron so combobox and select triggers
cannot drift
- Migrates the Studio **Database > Replication > Add destination >
Publication** field to `ComboboxTrigger`
- Updates `MultiSelectorTrigger` to replace `ChevronsUpDown` with the
same fixed `ChevronDown`, including **Connect > MCP > Feature groups**
in Studio
- Updates the canonical design-system combobox examples
- Leaves `Button` unchanged, so adopting combobox styling is explicit

| Before | After |
| --- | --- |
| <img width="1256" height="448" alt="CleanShot 2026-09-09 at 10 21
32@2x"
src="https://github.com/user-attachments/assets/bb7a6056-0b4e-4cb4-8dce-e92fa4f5afe7"
/> | <img width="1256" height="448" alt="CleanShot 2026-09-09 at 10 21
11@2x"
src="https://github.com/user-attachments/assets/860a06b9-617d-4d94-ac98-944316e9fdc3"
/> |
| <img width="1210" height="520" alt="CleanShot 2026-09-09 at 10 58
28@2x"
src="https://github.com/user-attachments/assets/e916d3a2-6a75-40eb-965a-56e0694ba447"
/> | <img width="1210" height="520" alt="CleanShot 2026-09-09 at 10 57
57@2x"
src="https://github.com/user-attachments/assets/c5f1b8ff-8083-47b8-8242-59116d085c00"
/> |

## Left for incremental migration

These existing Studio comboboxes still use bespoke button triggers and
will move to `ComboboxTrigger` in focused follow-up changes:

- **Database > Backups > Point in time > Timezone**
- **Authentication > Third-party auth > AWS region**
- **Account > Preferences > Timezone**
- Shared schema and function selectors

See https://github.com/supabase/supabase/pull/50157.

The organisation and project switchers in the top navigation are
intentionally out of scope. They are context switchers rather than
form-field comboboxes.

## To test

### Studio callsites

On the [Studio
preview](https://studio-staging-git-dnywh-featcombobox-trigger-supabase.vercel.app):

1. Open **Database > Replication > Add destination**.
2. Compare **Publication** with **Initial sync**. Both should use a
single down chevron and the same closed-field surface, border, sizing,
and hover treatment.
3. Open **Publication**, search for an option, and select it. The
popover should close and the selected publication should be shown.
4. Open **Connect** from the project header, select **MCP**, and check
**Feature groups**. Its multi-select trigger should use a single down
chevron.

### Design system

1. Check the [combobox
examples](https://design-system-git-dnywh-featcombobox-trigger-supabase.vercel.app/design-system/docs/components/combobox).
2. Check the [multi-select
examples](https://design-system-git-dnywh-featcombobox-trigger-supabase.vercel.app/design-system/docs/fragments/multi-select).
3. Both trigger types should use a single down chevron.
This commit is contained in:
Danny White authored and GitHub committed 2026-09-09 13:44:11 +10:00
1 parent 605c6ef065
commit 6a4cbefbcf
10 files changed
+130 -73

No files matched your search

@@ -19,18 +19,21 @@ See installation instructions for the [Popover](/docs/components/popover#install
```tsx
'use client'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Check } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import {
ComboboxTrigger,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
} from 'ui'
import { cn } from '@/lib/utils'
const frameworks = [
@@ -63,17 +66,15 @@ export function ComboboxDemo() {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
<ComboboxTrigger
aria-expanded={open}
className="w-[200px] justify-between"
data-state={open ? 'open' : 'closed'}
className={cn('w-[200px]', !value && 'text-foreground-lighter')}
>
{value
? frameworks.find((framework) => framework.value === value)?.label
: 'Select framework...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</ComboboxTrigger>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
@@ -1,9 +1,9 @@
'use client'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Check } from 'lucide-react'
import * as React from 'react'
import {
Button,
ComboboxTrigger,
Command,
CommandEmpty,
CommandGroup,
@@ -47,18 +47,15 @@ export default function ComboboxDemo() {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="default"
role="combobox"
size={'small'}
<ComboboxTrigger
aria-expanded={open}
className="w-[200px] justify-between"
iconRight={<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />}
data-state={open ? 'open' : 'closed'}
className={cn('w-[200px]', !value && 'text-foreground-lighter')}
>
{value
? frameworks.find((framework) => framework.value === value)?.label
: 'Select framework...'}
</Button>
</ComboboxTrigger>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
@@ -1,11 +1,13 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Check } from 'lucide-react'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
ComboboxTrigger,
Command,
CommandEmpty,
CommandGroup,
@@ -46,6 +48,7 @@ const FormSchema = z.object({
})
export default function ComboboxForm() {
const [open, setOpen] = useState(false)
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
})
@@ -69,28 +72,18 @@ export default function ComboboxForm() {
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Language</FormLabel>
<Popover>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="default"
role="combobox"
className={cn(
'w-[200px] justify-between',
!field.value && 'text-muted-foreground'
)}
size="small"
iconRight={
<ChevronsUpDown
className="ml-2 h-4 w-4 shrink-0 opacity-50"
strokeWidth={1}
/>
}
<ComboboxTrigger
aria-expanded={open}
data-state={open ? 'open' : 'closed'}
className={cn('w-[200px]', !field.value && 'text-foreground-lighter')}
>
{field.value
? languages.find((language) => language.value === field.value)?.label
: 'Select language'}
</Button>
</ComboboxTrigger>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
@@ -105,6 +98,7 @@ export default function ComboboxForm() {
key={language.value}
onSelect={() => {
form.setValue('language', language.value)
setOpen(false)
}}
>
<Check