From 6a4cbefbcf175cd8b7857d53949b447411f23f83 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Wed, 9 Sep 2026 13:44:11 +1000
Subject: [PATCH] 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 |
| --- | --- |
|
|
|
|
|
|
## 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.
---
.../content/docs/components/combobox.mdx | 23 ++++++------
.../default/example/combobox-demo.tsx | 15 +++-----
.../default/example/combobox-form.tsx | 28 ++++++--------
.../DestinationForm/PublicationsComboBox.tsx | 17 ++++-----
.../src/multi-select/multi-select.test.tsx | 1 +
.../src/multi-select/multi-select.tsx | 5 ++-
.../shadcn/ui/combobox-trigger.test.tsx | 22 +++++++++++
.../components/shadcn/ui/combobox-trigger.tsx | 37 +++++++++++++++++++
.../components/shadcn/ui/select-trigger.tsx | 21 +++++++++++
.../ui/src/components/shadcn/ui/select.tsx | 34 +++++------------
10 files changed, 130 insertions(+), 73 deletions(-)
create mode 100644 packages/ui/src/components/shadcn/ui/combobox-trigger.test.tsx
create mode 100644 packages/ui/src/components/shadcn/ui/combobox-trigger.tsx
create mode 100644 packages/ui/src/components/shadcn/ui/select-trigger.tsx
diff --git a/apps/design-system/content/docs/components/combobox.mdx b/apps/design-system/content/docs/components/combobox.mdx
index 21d55d94ae8..3ad33b0124e 100644
--- a/apps/design-system/content/docs/components/combobox.mdx
+++ b/apps/design-system/content/docs/components/combobox.mdx
@@ -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 (
-
+
diff --git a/apps/design-system/registry/default/example/combobox-demo.tsx b/apps/design-system/registry/default/example/combobox-demo.tsx
index 5216f8b4ada..280d9b772b5 100644
--- a/apps/design-system/registry/default/example/combobox-demo.tsx
+++ b/apps/design-system/registry/default/example/combobox-demo.tsx
@@ -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 (
- }
+ data-state={open ? 'open' : 'closed'}
+ className={cn('w-[200px]', !value && 'text-foreground-lighter')}
>
{value
? frameworks.find((framework) => framework.value === value)?.label
: 'Select framework...'}
-
+
diff --git a/apps/design-system/registry/default/example/combobox-form.tsx b/apps/design-system/registry/default/example/combobox-form.tsx
index a0a71ccd955..44545f313a2 100644
--- a/apps/design-system/registry/default/example/combobox-form.tsx
+++ b/apps/design-system/registry/default/example/combobox-form.tsx
@@ -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>({
resolver: zodResolver(FormSchema),
})
@@ -69,28 +72,18 @@ export default function ComboboxForm() {
render={({ field }) => (
Language
-
+
-
- }
+
{field.value
? languages.find((language) => language.value === field.value)?.label
: 'Select language'}
-
+
@@ -105,6 +98,7 @@ export default function ComboboxForm() {
key={language.value}
onSelect={() => {
form.setValue('language', language.value)
+ setOpen(false)
}}
>
-
+
{
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', () => {
diff --git a/packages/ui-patterns/src/multi-select/multi-select.tsx b/packages/ui-patterns/src/multi-select/multi-select.tsx
index a26425c0220..01de5c3354a 100644
--- a/packages/ui-patterns/src/multi-select/multi-select.tsx
+++ b/packages/ui-patterns/src/multi-select/multi-select.tsx
@@ -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
{showIcon && (
- {
+ it('matches the raised select trigger styling', () => {
+ render(Select publication)
+
+ 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')
+ })
+})
diff --git a/packages/ui/src/components/shadcn/ui/combobox-trigger.tsx b/packages/ui/src/components/shadcn/ui/combobox-trigger.tsx
new file mode 100644
index 00000000000..22f9f1227ae
--- /dev/null
+++ b/packages/ui/src/components/shadcn/ui/combobox-trigger.tsx
@@ -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, 'size'> & SelectTriggerVariantProps
+>(({ className, children, disabled, size, tabIndex, ...props }, ref) => {
+ const computedTabIndex = getExplicitTabIndex(tabIndex, disabled)
+
+ return (
+
+ )
+})
+ComboboxTrigger.displayName = 'ComboboxTrigger'
+
+export { ComboboxTrigger }
diff --git a/packages/ui/src/components/shadcn/ui/select-trigger.tsx b/packages/ui/src/components/shadcn/ui/select-trigger.tsx
new file mode 100644
index 00000000000..67c9fd3b4e4
--- /dev/null
+++ b/packages/ui/src/components/shadcn/ui/select-trigger.tsx
@@ -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
diff --git a/packages/ui/src/components/shadcn/ui/select.tsx b/packages/ui/src/components/shadcn/ui/select.tsx
index 9bacd99420f..7cde4b248cd 100644
--- a/packages/ui/src/components/shadcn/ui/select.tsx
+++ b/packages/ui/src/components/shadcn/ui/select.tsx
@@ -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,
- React.ComponentPropsWithoutRef &
- VariantProps
+ React.ComponentPropsWithoutRef
>(({ placeholder, ...props }, ref) => (
{placeholder} : 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['size']>
const SelectTrigger = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef &
- VariantProps
+ React.ComponentPropsWithoutRef & {
+ size?: SelectTriggerSize
+ }
>(({ className, children, size, ...props }, ref) => (
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,