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
@@ -1,10 +1,10 @@
import { useParams } from 'common'
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
import { Check, Plus } from 'lucide-react'
import { useState } from 'react'
import { ControllerRenderProps } from 'react-hook-form'
import {
Button,
cn,
ComboboxTrigger,
Command,
CommandEmpty,
CommandGroup,
@@ -78,19 +78,16 @@ export const PublicationsComboBox = ({
}}
>
<PopoverTrigger asChild>
<Button
variant="default"
<ComboboxTrigger
aria-expanded={isDropdownOpen}
data-state={isDropdownOpen ? 'open' : 'closed'}
size="small"
className={cn(
'w-full [&>span]:w-full text-left',
!selectedPublication && 'text-foreground-muted'
)}
iconRight={<ChevronsUpDown />}
className={cn(!selectedPublication && 'text-foreground-muted')}
name={field.name}
onBlur={field.onBlur}
>
{selectedPublication || 'Select publication'}
</Button>
</ComboboxTrigger>
</PopoverTrigger>
<PopoverContent
sameWidthAsTrigger
@@ -58,6 +58,7 @@ describe('multi-select', () => {
const trigger = screen.getByRole('combobox')
expect(trigger).toHaveClass('h-[26px]', 'p-0.5')
expect(trigger.firstElementChild).toHaveClass('gap-0.5')
expect(trigger.querySelector('.lucide-chevron-down')).toHaveAttribute('aria-hidden', 'true')
})
it('renders selected values with a custom label', () => {
@@ -1,7 +1,7 @@
'use client'
import { cva, VariantProps } from 'class-variance-authority'
import { Check, ChevronsUpDown, X as RemoveIcon } from 'lucide-react'
import { Check, ChevronDown, X as RemoveIcon } from 'lucide-react'
// @ts-ignore Required to avoid TS error: The inferred type of MultiSelectorContent cannot be named without a reference to @radix-ui
import type { Popover as PopoverPrimitive } from 'radix-ui'
import React, { Children, useEffect } from 'react'
@@ -489,7 +489,8 @@ const MultiSelectorTrigger = React.forwardRef<HTMLButtonElement, MultiSelectorTr
</div>
{showIcon && (
<ChevronsUpDown
<ChevronDown
aria-hidden="true"
size={16}
strokeWidth={1.5}
className="text-foreground-lighter shrink-0 ml-1.5 self-center"
@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ComboboxTrigger } from './combobox-trigger'
describe('ComboboxTrigger', () => {
it('matches the raised select trigger styling', () => {
render(<ComboboxTrigger aria-expanded={false}>Select publication</ComboboxTrigger>)
const trigger = screen.getByRole('combobox')
expect(trigger).toHaveTextContent('Select publication')
expect(trigger).toHaveClass(
'bg-control-raised',
'border-strong',
'cursor-pointer',
'focus-ring',
'text-left'
)
expect(trigger).not.toHaveClass('bg-background')
expect(trigger.querySelector('svg')).toHaveAttribute('aria-hidden', 'true')
})
})
@@ -0,0 +1,37 @@
'use client'
import { ChevronDown } from 'lucide-react'
import * as React from 'react'
import { cn } from '../../../lib/utils/cn'
import { getExplicitTabIndex } from '../../../lib/utils/getExplicitTabIndex'
import { selectTriggerVariants, type SelectTriggerVariantProps } from './select-trigger'
const ComboboxTrigger = React.forwardRef<
HTMLButtonElement,
Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'size'> & SelectTriggerVariantProps
>(({ className, children, disabled, size, tabIndex, ...props }, ref) => {
const computedTabIndex = getExplicitTabIndex(tabIndex, disabled)
return (
<button
ref={ref}
type="button"
role="combobox"
disabled={disabled}
className={cn(selectTriggerVariants({ size }), className)}
tabIndex={computedTabIndex}
{...props}
>
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
<ChevronDown
aria-hidden="true"
className="h-4 w-4 shrink-0 text-foreground-lighter"
strokeWidth={1.5}
/>
</button>
)
})
ComboboxTrigger.displayName = 'ComboboxTrigger'
export { ComboboxTrigger }
@@ -0,0 +1,21 @@
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from '../../../lib/constants'
export const selectTriggerVariants = cva(
'flex w-full cursor-pointer items-center justify-between rounded-md border border-strong hover:border-control-hover bg-control-raised text-xs data-[placeholder]:text-foreground-lighter ring-border-control focus-ring disabled:cursor-not-allowed disabled:opacity-50 transition-colors duration-200 data-[state=open]:border-control-hover gap-2 [&>span]:truncate text-left',
{
variants: {
size: {
...SIZE_VARIANTS,
},
},
defaultVariants: {
size: SIZE_VARIANTS_DEFAULT,
},
}
)
export type SelectTriggerVariantProps = VariantProps<typeof selectTriggerVariants>
+10 -24
View File
@@ -1,12 +1,13 @@
'use client'
import { cva, VariantProps } from 'class-variance-authority'
import { type VariantProps } from 'class-variance-authority'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { Select as SelectPrimitive } from 'radix-ui'
import * as React from 'react'
import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from '../../../lib/constants'
import { cn } from '../../../lib/utils/cn'
import { ComboboxTrigger } from './combobox-trigger'
import { selectTriggerVariants } from './select-trigger'
const Select = SelectPrimitive.Root
@@ -16,8 +17,7 @@ const SelectGroup = SelectPrimitive.Group
// https://github.com/radix-ui/primitives/issues/2578#issuecomment-1890801041 for more info.
const SelectValue = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Value>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Value> &
VariantProps<typeof SelectTriggerVariants>
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Value>
>(({ placeholder, ...props }, ref) => (
<SelectPrimitive.Value
placeholder={typeof placeholder === 'string' ? <span>{placeholder}</span> : placeholder}
@@ -28,32 +28,17 @@ const SelectValue = React.forwardRef<
SelectValue.displayName = SelectPrimitive.Value.displayName
const SelectTriggerVariants = cva('', {
variants: {
size: {
...SIZE_VARIANTS,
},
},
defaultVariants: {
size: SIZE_VARIANTS_DEFAULT,
},
})
type SelectTriggerSize = NonNullable<VariantProps<typeof selectTriggerVariants>['size']>
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
VariantProps<typeof SelectTriggerVariants>
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
size?: SelectTriggerSize
}
>(({ className, children, size, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex w-full items-center justify-between rounded-md border border-strong hover:border-control-hover bg-control-raised text-xs data-[placeholder]:text-foreground-lighter ring-border-control focus-ring disabled:cursor-not-allowed disabled:opacity-50 transition-colors duration-200',
'data-[state=open]:border-control-hover',
'gap-2',
'[&>span]:truncate text-left', // [kemal] This is to prevent double lines rendering when a string is particularly long.
SelectTriggerVariants({ size }),
className
)}
className={cn(selectTriggerVariants({ size }), className)}
tabIndex={0}
{...props}
>
@@ -187,6 +172,7 @@ const SelectSeparator = React.forwardRef<
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
ComboboxTrigger,
Select,
SelectContent,
SelectGroup,