mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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
|
||||
|
||||
+7
-10
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user