From d8d4bf6f76c556797a5cd1efc229a2d817b00d6d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 2 Sep 2026 18:47:31 -0600 Subject: [PATCH 01/13] feat(ui): add combobox --- .changeset/calm-combs-search.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 12 + packages/swingset/src/stories/combobox.mdx | 64 ++++++ .../swingset/src/stories/combobox.stories.tsx | 81 +++++++ .../components/combobox/combobox.styles.ts | 92 ++++++++ .../components/combobox/combobox.test.tsx | 208 ++++++++++++++++++ .../mosaic/components/combobox/combobox.tsx | 167 ++++++++++++++ .../src/mosaic/components/combobox/index.ts | 19 ++ packages/ui/src/mosaic/styles/index.ts | 10 + 10 files changed, 656 insertions(+) create mode 100644 .changeset/calm-combs-search.md create mode 100644 packages/swingset/src/stories/combobox.mdx create mode 100644 packages/swingset/src/stories/combobox.stories.tsx create mode 100644 packages/ui/src/mosaic/components/combobox/combobox.styles.ts create mode 100644 packages/ui/src/mosaic/components/combobox/combobox.test.tsx create mode 100644 packages/ui/src/mosaic/components/combobox/combobox.tsx create mode 100644 packages/ui/src/mosaic/components/combobox/index.ts diff --git a/.changeset/calm-combs-search.md b/.changeset/calm-combs-search.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/calm-combs-search.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index e1392f4f947..d0b68d9e9fb 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -46,6 +46,7 @@ const docModules: Record> = { banner: dynamic(() => import('../stories/banner.mdx')), button: dynamic(() => import('../stories/button.mdx')), card: dynamic(() => import('../stories/card.component.mdx')), + combobox: dynamic(() => import('../stories/combobox.mdx')), input: dynamic(() => import('../stories/input.mdx')), 'input-group': dynamic(() => import('../stories/input-group.mdx')), item: dynamic(() => import('../stories/item.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index c1f60a6e2b8..f429147e0c6 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -26,6 +26,11 @@ import { Disabled, meta as buttonMeta, Primary, Sizes } from '../stories/button. import { Default as CardDefault, meta as cardComponentMeta } from '../stories/card.component.stories'; import { meta as collapsibleMeta } from '../stories/collapsible.stories'; import { meta as comboboxPrimitiveMeta } from '../stories/combobox.primitive.stories'; +import { + Default as ComboboxDefault, + meta as comboboxMeta, + Scrolling as ComboboxScrolling, +} from '../stories/combobox.stories'; import { Default as DestructiveDefault, meta as destructiveMeta, @@ -266,6 +271,12 @@ const drawerComponentModule: StoryModule = { const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault }; +const comboboxModule: StoryModule = { + meta: comboboxMeta, + Default: ComboboxDefault, + Scrolling: ComboboxScrolling, +}; + const avatarModule: StoryModule = { meta: avatarMeta, Primary: AvatarPrimary, @@ -556,6 +567,7 @@ export const registry: StoryModule[] = [ bannerModule, buttonModule, cardComponentModule, + comboboxModule, flowComponentModule, inputModule, inputGroupModule, diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx new file mode 100644 index 00000000000..01d87d9bc45 --- /dev/null +++ b/packages/swingset/src/stories/combobox.mdx @@ -0,0 +1,64 @@ +import * as ComboboxStories from './combobox.stories'; + +# Combobox + +`Combobox` adds Mosaic styling to the headless `Autocomplete` behavior: text entry, listbox ARIA, +keyboard navigation, selection, positioning, and the shared scrolling treatment. + +## Example + +Use the trigger to show every option, or type to open and filter the list. Use the arrow keys and +Enter to select an option. + + + +Filtering stays with the caller: read `inputValue` and render only the matching options. +`Combobox.Popup` owns its portal, positioner, surface, and scrolling viewport. + +## Inline lists + +Use `List` when the searchable list already lives in another floating surface, such as a country +picker inside a popover. Use the ghost input variant when it needs an icon or adjacent text. +This avoids both a second input implementation and a nested popup. + +```tsx + + + + + + + + {countries.map(country => ( + + {country.name} + + ))} + + +``` + +## Scrolling + +Long lists receive the shared ScrollArea fade and scrollbar automatically. + + + +## Parts + +| Part | Description | +| ------------------ | --------------------------------------------------------------------------------- | +| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. | +| `Combobox.Input` | Autocomplete input; renders Mosaic `Input` unless composed through another input. | +| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. | +| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. | +| `Combobox.List` | Scrollable inline listbox. | +| `Combobox.Option` | Selectable option with active, selected, and disabled states. | +| `Combobox.Empty` | Empty result message. | diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx new file mode 100644 index 00000000000..4a17483fb73 --- /dev/null +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Combobox } from '@clerk/ui/mosaic/components/combobox'; +import { Field } from '@clerk/ui/mosaic/components/field'; +import { Icon } from '@clerk/ui/mosaic/components/icon'; +import { InputGroup } from '@clerk/ui/mosaic/components/input-group'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './combobox.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Combobox', + source: 'packages/ui/src/mosaic/components/combobox/combobox.tsx', +}; + +const fruits = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Fig', 'Grape', 'Pear', 'Plum']; + +function FruitCombobox({ options = fruits }: { options?: string[] }) { + const [query, setQuery] = useState(''); + const filtered = options.filter(option => option.toLowerCase().includes(query.toLowerCase())); + + return ( + + + Fruit + + + + } + > + + + + + {filtered.length > 0 ? ( + filtered.map(option => ( + + {option} + + )) + ) : ( + No fruit found + )} + + + ); +} + +export function Default() { + return ; +} + +const manyFruits = Array.from({ length: 40 }, (_, index) => `Fruit ${index + 1}`); + +export function Scrolling() { + return ; +} diff --git a/packages/ui/src/mosaic/components/combobox/combobox.styles.ts b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts new file mode 100644 index 00000000000..df38efa4c4b --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts @@ -0,0 +1,92 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontFamilyVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + positioner: { + outline: 'none', + }, + popup: { + borderRadius: radiusVars['--cl-radius-lg'], + outline: 'none', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + color: colorVars['--cl-color-card-foreground'], + opacity: { + default: 1, + ':where([data-ending-style], [data-starting-style])': 0, + }, + scale: { + default: 1, + ':where([data-ending-style], [data-starting-style])': 0.96, + }, + transformOrigin: 'var(--cl-transform-origin)', + transitionDuration: { + default: '150ms', + '@media (prefers-reduced-motion: reduce)': '0.01ms', + }, + transitionProperty: 'opacity, scale', + transitionTimingFunction: 'ease-out', + maxHeight: 'var(--cl-available-height)', + minWidth: '12.5rem', + }, + viewport: { + padding: space['1'], + gap: space['0.5'], + display: 'flex', + flexDirection: 'column', + maxHeight: '16rem', + }, + list: { + padding: space['1'], + gap: space['0.5'], + display: 'flex', + flexDirection: 'column', + maxHeight: '16rem', + }, + option: { + borderRadius: radiusVars['--cl-radius-md'], + gap: space['2'], + outline: { + default: 'none', + '@media (forced-colors: active)': { + default: null, + ':is([data-active])': '2px solid CanvasText', + }, + }, + paddingInline: space['2'], + alignItems: 'center', + backgroundColor: { + default: 'transparent', + ':is([data-active])': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, + '@media (hover: hover)': { + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, + }, + }, + cursor: { + default: 'pointer', + ':is([data-disabled])': 'not-allowed', + }, + display: 'flex', + flexShrink: 0, + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + opacity: { + default: 1, + ':is([data-disabled])': 0.5, + }, + height: space['9'], + }, + empty: { + paddingBlock: space['6'], + paddingInline: space['3'], + color: colorVars['--cl-color-neutral-faded'], + fontFamily: fontFamilyVars['--cl-font-family-sans'], + fontSize: typeScaleVars['--cl-text-sm-size'], + textAlign: 'center', + }, +}); diff --git a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx new file mode 100644 index 00000000000..d5cc2d7a0af --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx @@ -0,0 +1,208 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Field } from '../field'; +import { Icon } from '../icon'; +import { InputGroup } from '../input-group'; +import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; +import { Combobox } from './combobox'; + +const scrollClasses = stylex.props(...scrollAreaViewport()).className?.split(' ') ?? []; +const rootClasses = stylex.props(scrollAreaRoot).className?.split(' ') ?? []; +const viewportOnlyClasses = scrollClasses.filter(name => !rootClasses.includes(name)); + +function FloatingCombobox(props?: { onValueChange?: (value: string) => void }) { + return ( + + + + + } + > + + + + + Apple + + + Banana + + + + ); +} + +describe('Mosaic Combobox', () => { + it('opens its options from a composed trigger', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); + expect(trigger).toHaveClass('cl-button', 'cl-input-group-action', 'cl-combobox-trigger'); + expect(trigger).toHaveAttribute('data-size', 'xs'); + expect(trigger).toHaveAttribute('data-variant', 'ghost'); + expect(trigger).toHaveAttribute('data-color', 'neutral'); + + await user.click(trigger); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(trigger).toHaveAttribute('data-open', ''); + expect(input).toHaveFocus(); + }); + + it('renders a field-aware, sized input', () => { + render( + + Fruit + + + + Apple + + + Choose a fruit + , + ); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + expect(input).toHaveClass('cl-input', 'cl-combobox-input'); + expect(input).toHaveAttribute('data-size', 'lg'); + expect(input).toBeRequired(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription('Choose a fruit'); + }); + + it('opens a styled floating popup when the user types', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole('combobox', { name: 'Fruit' }), 'a'); + + const listbox = screen.getByRole('listbox'); + const popup = listbox.querySelector('.cl-combobox-popup'); + const viewport = popup?.querySelector('.cl-combobox-viewport'); + expect(listbox).toHaveClass('cl-combobox-positioner'); + expect(popup).toBeInTheDocument(); + expect(viewport).toHaveClass(...scrollClasses); + expect(viewportOnlyClasses).not.toHaveLength(0); + expect(viewportOnlyClasses.filter(name => popup?.classList.contains(name))).toEqual([]); + expect(screen.getByRole('option', { name: 'Apple' }).closest('.cl-combobox-viewport')).toBe(viewport); + }); + + it('selects an option and restores its label to the input', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + await user.type(input, 'b'); + await user.click(screen.getByRole('option', { name: 'Banana' })); + + expect(onValueChange).toHaveBeenCalledWith('banana'); + expect(input).toHaveValue('Banana'); + expect(input).toHaveFocus(); + }); + + it('supports keyboard selection', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + await user.type(input, 'a'); + await user.keyboard('{Enter}'); + + expect(onValueChange).toHaveBeenCalledWith('apple'); + }); + + it('styles disabled options and prevents their selection', async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render( + + + + + Apple + + + , + ); + + const option = screen.getByRole('option', { name: 'Apple' }); + expect(option).toHaveClass('cl-combobox-option'); + expect(option).toHaveAttribute('aria-disabled', 'true'); + await user.click(option); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('uses the ghost Input variant inside an input group', () => { + render( + + + + + + + + United States + + , + ); + + expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveClass('cl-input', 'cl-combobox-input'); + expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('data-size', 'lg'); + expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('data-variant', 'ghost'); + expect(screen.getByRole('listbox')).toHaveClass('cl-combobox-list', ...scrollClasses); + expect(screen.getByRole('option', { name: 'United States' })).toHaveClass('cl-combobox-option'); + }); + + it('renders a reusable empty state', () => { + render(No matches); + + expect(screen.getByText('No matches')).toHaveClass('cl-combobox-empty'); + }); +}); diff --git a/packages/ui/src/mosaic/components/combobox/combobox.tsx b/packages/ui/src/mosaic/components/combobox/combobox.tsx new file mode 100644 index 00000000000..cb38133673b --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.tsx @@ -0,0 +1,167 @@ +'use client'; + +import type { AutocompleteProps } from '@clerk/headless/autocomplete'; +import { Autocomplete as Primitive } from '@clerk/headless/autocomplete'; +import { useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Input, type InputVariant } from '../input'; +import { useOptionalInputGroupContext } from '../input-group/input-group.context'; +import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; +import { styles } from './combobox.styles'; + +export type ComboboxRootProps = AutocompleteProps; +export type ComboboxSize = 'sm' | 'md' | 'lg'; +export type ComboboxTriggerProps = MosaicComponentProps<'button'>; + +export const ComboboxTrigger = React.forwardRef(function MosaicComboboxTrigger( + { className, style, ...props }, + ref, +) { + return ( + + ); +}); + +export interface ComboboxInputProps extends Omit, 'size'> { + size?: ComboboxSize; + variant?: InputVariant; +} + +export const ComboboxInput = React.forwardRef(function MosaicComboboxInput( + { size: sizeProp, variant = 'default', render, className, style, ...rest }, + ref, +) { + const inputGroup = useOptionalInputGroupContext(); + const size = inputGroup?.size ?? sizeProp ?? 'md'; + + return ( + + ) + } + {...mergeStyleProps(themeProps('combobox-input', { size, variant }), className, style)} + {...rest} + /> + ); +}); + +export interface ComboboxPopupProps extends MosaicComponentProps<'div'> { + /** Container the combobox portals into. Defaults to `document.body`. */ + portalRoot?: React.ComponentPropsWithoutRef['root']; +} + +/** Floating listbox surface. Portal and positioning are handled internally. */ +export const ComboboxPopup = React.forwardRef(function MosaicComboboxPopup( + { portalRoot, className, style, children, ...rest }, + ref, +) { + return ( + + + +
+ {children} +
+
+
+
+ ); +}); + +export type ComboboxListProps = MosaicComponentProps<'div'>; + +/** Scrollable listbox used when the combobox is embedded in another surface. */ +export const ComboboxList = React.forwardRef(function MosaicComboboxList( + { className, style, ...rest }, + ref, +) { + return ( + + ); +}); + +export interface ComboboxOptionProps extends MosaicComponentProps<'div'> { + value: string; + label?: string; + disabled?: boolean; +} + +export const ComboboxOption = React.forwardRef(function MosaicComboboxOption( + { className, style, ...rest }, + ref, +) { + return ( + + ); +}); + +export type ComboboxEmptyProps = MosaicComponentProps<'p'>; + +export const ComboboxEmpty = React.forwardRef(function MosaicComboboxEmpty( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps(themeProps('combobox-empty'), stylex.props(reset.base, styles.empty), className, style), + ...rest, + }, + }); +}); + +export const Combobox = { + Root: Primitive.Root, + Input: ComboboxInput, + Trigger: ComboboxTrigger, + Popup: ComboboxPopup, + List: ComboboxList, + Option: ComboboxOption, + Empty: ComboboxEmpty, +}; diff --git a/packages/ui/src/mosaic/components/combobox/index.ts b/packages/ui/src/mosaic/components/combobox/index.ts new file mode 100644 index 00000000000..27d3254b345 --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/index.ts @@ -0,0 +1,19 @@ +export { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxList, + ComboboxOption, + ComboboxPopup, + ComboboxTrigger, +} from './combobox'; +export type { + ComboboxEmptyProps, + ComboboxInputProps, + ComboboxListProps, + ComboboxOptionProps, + ComboboxPopupProps, + ComboboxRootProps, + ComboboxSize, + ComboboxTriggerProps, +} from './combobox'; diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index b869b0742e6..2943f42f266 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -26,6 +26,16 @@ export type { DrawerTitleProps, DrawerTriggerProps, } from '../components/drawer'; +export { Combobox } from '../components/combobox'; +export type { + ComboboxEmptyProps, + ComboboxInputProps, + ComboboxListProps, + ComboboxOptionProps, + ComboboxPopupProps, + ComboboxRootProps, + ComboboxSize, +} from '../components/combobox'; export { Dialog, createConfirmHandle, useConfirmedClose } from '../components/dialog'; export type { ConfirmHandle, From 061e4dee64691e9ce62de68b74ebf49becb6c7c1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 12:24:37 -0600 Subject: [PATCH 02/13] docs(swingset): mark combobox as work in progress --- packages/swingset/src/stories/combobox.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx index 4a17483fb73..d96a82614ce 100644 --- a/packages/swingset/src/stories/combobox.stories.tsx +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -12,6 +12,7 @@ export { default as __source } from './combobox.stories?raw'; export const meta: StoryMeta = { group: 'Components', + status: 'wip', title: 'Combobox', source: 'packages/ui/src/mosaic/components/combobox/combobox.tsx', }; From 1c0714f411ecdf6cb090b72c7329f468b031916e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 14:17:31 -0600 Subject: [PATCH 03/13] test(ui): remove combobox CSS assertions --- .../mosaic/components/combobox/combobox.test.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx index d5cc2d7a0af..c312beafc34 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx +++ b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx @@ -1,4 +1,3 @@ -import * as stylex from '@stylexjs/stylex'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -7,13 +6,8 @@ import { describe, expect, it, vi } from 'vitest'; import { Field } from '../field'; import { Icon } from '../icon'; import { InputGroup } from '../input-group'; -import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; import { Combobox } from './combobox'; -const scrollClasses = stylex.props(...scrollAreaViewport()).className?.split(' ') ?? []; -const rootClasses = stylex.props(scrollAreaRoot).className?.split(' ') ?? []; -const viewportOnlyClasses = scrollClasses.filter(name => !rootClasses.includes(name)); - function FloatingCombobox(props?: { onValueChange?: (value: string) => void }) { return ( @@ -101,7 +95,7 @@ describe('Mosaic Combobox', () => { expect(input).toHaveAccessibleDescription('Choose a fruit'); }); - it('opens a styled floating popup when the user types', async () => { + it('opens a floating popup when the user types', async () => { const user = userEvent.setup(); render(); @@ -112,9 +106,7 @@ describe('Mosaic Combobox', () => { const viewport = popup?.querySelector('.cl-combobox-viewport'); expect(listbox).toHaveClass('cl-combobox-positioner'); expect(popup).toBeInTheDocument(); - expect(viewport).toHaveClass(...scrollClasses); - expect(viewportOnlyClasses).not.toHaveLength(0); - expect(viewportOnlyClasses.filter(name => popup?.classList.contains(name))).toEqual([]); + expect(viewport).toBeInTheDocument(); expect(screen.getByRole('option', { name: 'Apple' }).closest('.cl-combobox-viewport')).toBe(viewport); }); @@ -196,7 +188,7 @@ describe('Mosaic Combobox', () => { expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveClass('cl-input', 'cl-combobox-input'); expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('data-size', 'lg'); expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('data-variant', 'ghost'); - expect(screen.getByRole('listbox')).toHaveClass('cl-combobox-list', ...scrollClasses); + expect(screen.getByRole('listbox')).toHaveClass('cl-combobox-list'); expect(screen.getByRole('option', { name: 'United States' })).toHaveClass('cl-combobox-option'); }); From 30d4ad7331aebe95e8c838fd69dadf484b62e9ab Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 15:47:44 -0600 Subject: [PATCH 04/13] refactor(ui): use input group start in combobox examples --- packages/swingset/src/stories/combobox.mdx | 4 ++-- packages/ui/src/mosaic/components/combobox/combobox.test.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index 01d87d9bc45..7b3bcedbc3c 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -27,9 +27,9 @@ This avoids both a second input implementation and a nested popup. ```tsx - + + diff --git a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx index c312beafc34..71d24822d1f 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx +++ b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx @@ -167,12 +167,12 @@ describe('Mosaic Combobox', () => { render( - + + Date: Wed, 9 Sep 2026 16:01:20 -0600 Subject: [PATCH 05/13] refactor(ui): compose combobox triggers with input group slots --- .../swingset/src/stories/combobox.stories.tsx | 26 ++++++++-------- .../components/combobox/combobox.test.tsx | 30 +++++++++---------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx index d96a82614ce..98330bba223 100644 --- a/packages/swingset/src/stories/combobox.stories.tsx +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -1,5 +1,6 @@ 'use client'; +import { Button } from '@clerk/ui/mosaic/components/button'; import { Combobox } from '@clerk/ui/mosaic/components/combobox'; import { Field } from '@clerk/ui/mosaic/components/field'; import { Icon } from '@clerk/ui/mosaic/components/icon'; @@ -35,21 +36,18 @@ function FruitCombobox({ options = fruits }: { options?: string[] }) { variant='ghost' placeholder='Search fruit…' /> - + } + > + + + diff --git a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx index 71d24822d1f..65d148c5d47 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx +++ b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { Button } from '../button'; import { Field } from '../field'; import { Icon } from '../icon'; import { InputGroup } from '../input-group'; @@ -17,21 +18,18 @@ function FloatingCombobox(props?: { onValueChange?: (value: string) => void }) { aria-label='Fruit' placeholder='Search fruit' /> - + } + > + + + { const input = screen.getByRole('combobox', { name: 'Fruit' }); const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); - expect(trigger).toHaveClass('cl-button', 'cl-input-group-action', 'cl-combobox-trigger'); - expect(trigger).toHaveAttribute('data-size', 'xs'); + expect(trigger).toHaveClass('cl-button', 'cl-combobox-trigger'); + expect(trigger).toHaveAttribute('data-size', 'sm'); expect(trigger).toHaveAttribute('data-variant', 'ghost'); expect(trigger).toHaveAttribute('data-color', 'neutral'); From 66097711bbcbb11a45375f5b19fe576317eb5640 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 16:46:42 -0600 Subject: [PATCH 06/13] fix(ui): automatically anchor combobox popup to input group --- .../components/combobox/combobox.test.tsx | 53 +++++++++++++++++-- .../mosaic/components/combobox/combobox.tsx | 32 ++++++++++- .../src/mosaic/components/combobox/index.ts | 1 + .../input-group/input-group.context.ts | 1 + .../components/input-group/input-group.tsx | 8 ++- 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx index 65d148c5d47..8f4ea2a4dd3 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx +++ b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -9,10 +9,10 @@ import { Icon } from '../icon'; import { InputGroup } from '../input-group'; import { Combobox } from './combobox'; -function FloatingCombobox(props?: { onValueChange?: (value: string) => void }) { +function FloatingCombobox(props?: { onValueChange?: (value: string) => void; anchor?: HTMLElement }) { return ( - + void }) { - + void }) { } describe('Mosaic Combobox', () => { + it('positions the popup against the full input group', async () => { + const user = userEvent.setup(); + render(); + const measureGroup = vi.spyOn(screen.getByTestId('fruit-group'), 'getBoundingClientRect'); + + await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); + + await waitFor(() => expect(measureGroup).toHaveBeenCalled()); + expect(screen.getByRole('combobox', { name: 'Fruit' })).toHaveFocus(); + }); + it('opens its options from a composed trigger', async () => { const user = userEvent.setup(); render(); @@ -68,6 +79,40 @@ describe('Mosaic Combobox', () => { expect(input).toHaveFocus(); }); + it('allows an explicit anchor to override the input group', async () => { + const user = userEvent.setup(); + const anchor = document.createElement('div'); + const measureAnchor = vi.spyOn(anchor, 'getBoundingClientRect'); + render(); + const measureGroup = vi.spyOn(screen.getByTestId('fruit-group'), 'getBoundingClientRect'); + + await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); + + await waitFor(() => expect(measureAnchor).toHaveBeenCalled()); + expect(measureGroup).not.toHaveBeenCalled(); + }); + + it('anchors to the input without a group and opens with the keyboard', async () => { + const user = userEvent.setup(); + render( + + + + Apple + + , + ); + const input = screen.getByRole('combobox', { name: 'Fruit' }); + const measureInput = vi.spyOn(input, 'getBoundingClientRect'); + + await user.tab(); + await user.keyboard('{ArrowDown}'); + + await waitFor(() => expect(measureInput).toHaveBeenCalled()); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(input).toHaveFocus(); + }); + it('renders a field-aware, sized input', () => { render( ; +const ComboboxAnchorContext = React.createContext<{ + anchor: HTMLElement | null; + setAnchor: React.Dispatch>; +} | null>(null); + +export function ComboboxRoot({ sideOffset = 8, ...props }: ComboboxRootProps) { + const [anchor, setAnchor] = React.useState(null); + const context = React.useMemo(() => ({ anchor, setAnchor }), [anchor]); + return ( + + + + ); +} + export const ComboboxTrigger = React.forwardRef(function MosaicComboboxTrigger( { className, style, ...props }, ref, @@ -41,6 +59,12 @@ export const ComboboxInput = React.forwardRef { + setAnchor?.(groupElement ?? null); + return () => setAnchor?.(null); + }, [groupElement, setAnchor]); const size = inputGroup?.size ?? sizeProp ?? 'md'; return ( @@ -61,18 +85,22 @@ export const ComboboxInput = React.forwardRef { + /** Overrides positioning against the input group or standalone input. */ + anchor?: React.ComponentPropsWithoutRef['anchor']; /** Container the combobox portals into. Defaults to `document.body`. */ portalRoot?: React.ComponentPropsWithoutRef['root']; } /** Floating listbox surface. Portal and positioning are handled internally. */ export const ComboboxPopup = React.forwardRef(function MosaicComboboxPopup( - { portalRoot, className, style, children, ...rest }, + { anchor, portalRoot, className, style, children, ...rest }, ref, ) { + const context = React.useContext(ComboboxAnchorContext); return ( void; diff --git a/packages/ui/src/mosaic/components/input-group/input-group.tsx b/packages/ui/src/mosaic/components/input-group/input-group.tsx index 707876160a9..44cf873cb50 100644 --- a/packages/ui/src/mosaic/components/input-group/input-group.tsx +++ b/packages/ui/src/mosaic/components/input-group/input-group.tsx @@ -28,15 +28,19 @@ const Root = React.forwardRef(function Mosa const field = useOptionalFieldContext(); const disabled = disabledProp ?? field?.disabled ?? false; const invalid = invalidProp ?? field?.invalid ?? false; + const [groupElement, setGroupElement] = React.useState(null); const inputElementRef = React.useRef(null); const inputRef = React.useCallback((node: HTMLInputElement | null) => { inputElementRef.current = node; }, []); - const context = React.useMemo(() => ({ disabled, invalid, inputRef, size }), [disabled, invalid, inputRef, size]); + const context = React.useMemo( + () => ({ element: groupElement, disabled, invalid, inputRef, size }), + [groupElement, disabled, invalid, inputRef, size], + ); const element = useRender({ defaultTagName: 'div', render, - ref, + ref: [ref, setGroupElement], props: { ...mergeStyleProps( themeProps('input-group', { size, disabled, invalid }), From 4ff2958a4f0a2aa8f8c7f0d094a0771983b201f3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 16:47:00 -0600 Subject: [PATCH 07/13] docs(swingset): show combobox building blocks in examples --- packages/swingset/src/stories/combobox.mdx | 2 + .../swingset/src/stories/combobox.stories.tsx | 59 +++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index 7b3bcedbc3c..f7b781fce33 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -17,6 +17,8 @@ Enter to select an option. Filtering stays with the caller: read `inputValue` and render only the matching options. `Combobox.Popup` owns its portal, positioner, surface, and scrolling viewport. +The popup automatically aligns with the surrounding input group, or the input when used alone. +Use `Popup`'s `anchor` prop to override this. `Root`'s `sideOffset` defaults to `8`. ## Inline lists diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx index 98330bba223..c113c1b433a 100644 --- a/packages/swingset/src/stories/combobox.stories.tsx +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -18,10 +18,9 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/components/combobox/combobox.tsx', }; -const fruits = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Fig', 'Grape', 'Pear', 'Plum']; - -function FruitCombobox({ options = fruits }: { options?: string[] }) { +export function Default() { const [query, setQuery] = useState(''); + const options = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Fig', 'Grape', 'Pear', 'Plum']; const filtered = options.filter(option => option.toLowerCase().includes(query.toLowerCase())); return ( @@ -69,12 +68,52 @@ function FruitCombobox({ options = fruits }: { options?: string[] }) { ); } -export function Default() { - return ; -} - -const manyFruits = Array.from({ length: 40 }, (_, index) => `Fruit ${index + 1}`); - export function Scrolling() { - return ; + const [query, setQuery] = useState(''); + const options = Array.from({ length: 40 }, (_, index) => `Fruit ${index + 1}`); + const filtered = options.filter(option => option.toLowerCase().includes(query.toLowerCase())); + + return ( + + + Fruit + + + + } + > + + + + + + {filtered.length > 0 ? ( + filtered.map(option => ( + + {option} + + )) + ) : ( + No fruit found + )} + + + ); } From 20a4a88b5ce10d887d528416792d396b58a12709 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 17:55:03 -0600 Subject: [PATCH 08/13] feat(ui): make combobox selection-aware --- .../components/combobox/combobox.styles.ts | 6 ++ .../components/combobox/combobox.test.tsx | 86 ++++++++++++++++++- .../mosaic/components/combobox/combobox.tsx | 56 ++++++++---- .../src/mosaic/components/combobox/index.ts | 3 + 4 files changed, 135 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/mosaic/components/combobox/combobox.styles.ts b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts index df38efa4c4b..31fda1947b4 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.styles.ts +++ b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts @@ -89,4 +89,10 @@ export const styles = stylex.create({ fontSize: typeScaleVars['--cl-text-sm-size'], textAlign: 'center', }, + indicator: { + alignItems: 'center', + display: 'inline-flex', + flexShrink: 0, + marginInlineStart: 'auto', + }, }); diff --git a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx index 8f4ea2a4dd3..04d15bf47ce 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.test.tsx +++ b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx @@ -9,7 +9,7 @@ import { Icon } from '../icon'; import { InputGroup } from '../input-group'; import { Combobox } from './combobox'; -function FloatingCombobox(props?: { onValueChange?: (value: string) => void; anchor?: HTMLElement }) { +function FloatingCombobox(props?: { onValueChange?: (value: string | null) => void; anchor?: HTMLElement }) { return ( @@ -37,12 +37,14 @@ function FloatingCombobox(props?: { onValueChange?: (value: string) => void; anc label='Apple' > Apple + Banana + @@ -50,6 +52,37 @@ function FloatingCombobox(props?: { onValueChange?: (value: string) => void; anc } describe('Mosaic Combobox', () => { + it('removes the check and selection when the input is cleared', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); + await user.click(screen.getByRole('option', { name: 'Apple' })); + await user.clear(screen.getByRole('combobox')); + expect(onValueChange).toHaveBeenLastCalledWith(null); + expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('aria-selected', 'false'); + await user.keyboard('{Escape}'); + expect(screen.getByRole('combobox')).toHaveValue(''); + }); + it('keeps the check on the selection while another option is highlighted', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); + await user.click(trigger); + expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); + await user.click(screen.getByRole('option', { name: 'Apple' })); + await user.click(trigger); + await user.hover(screen.getByRole('option', { name: 'Banana' })); + expect(screen.getByTestId('apple-check')).toBeVisible(); + expect(screen.getByTestId('apple-check')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.queryByTestId('banana-check')).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('aria-selected', 'true'); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(trigger); + expect(screen.getByTestId('banana-check')).toBeVisible(); + expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); + }); it('positions the popup against the full input group', async () => { const user = userEvent.setup(); render(); @@ -179,6 +212,57 @@ describe('Mosaic Combobox', () => { expect(onValueChange).toHaveBeenCalledWith('apple'); }); + it('discards search text on dismiss without changing the selected value', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render(); + const input = screen.getByRole('combobox', { name: 'Fruit' }); + await user.type(input, 'a'); + await user.keyboard('{Enter}'); + await user.type(input, ' not a fruit'); + await user.keyboard('{Escape}'); + + expect(input).toHaveValue('Apple'); + expect(onValueChange).toHaveBeenCalledExactlyOnceWith('apple'); + }); + + it('keeps the selected label while reopening the unfiltered collection', async () => { + const user = userEvent.setup(); + render( + + + + + item} + empty={No matches} + > + {item => ( + + {item} + + )} + + + , + ); + const input = screen.getByRole('combobox'); + await user.type(input, 'Ban'); + expect(screen.getAllByRole('option')).toHaveLength(1); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await user.click(screen.getByRole('button', { name: 'Show fruits' })); + expect(input).toHaveValue('Banana'); + expect(screen.getAllByRole('option')).toHaveLength(2); + await user.type(input, 'unknown'); + expect(screen.getByText('No matches')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(input).toHaveValue('Banana'); + }); + it('styles disabled options and prevents their selection', async () => { const onValueChange = vi.fn(); const user = userEvent.setup(); diff --git a/packages/ui/src/mosaic/components/combobox/combobox.tsx b/packages/ui/src/mosaic/components/combobox/combobox.tsx index 9aa11123df6..655ea194a75 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.tsx +++ b/packages/ui/src/mosaic/components/combobox/combobox.tsx @@ -1,7 +1,7 @@ 'use client'; import type { AutocompleteProps } from '@clerk/headless/autocomplete'; -import { Autocomplete as Primitive } from '@clerk/headless/autocomplete'; +import { Autocomplete } from '@clerk/headless/autocomplete'; import { useRender } from '@clerk/headless/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -9,14 +9,16 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; +import { Icon } from '../icon'; import { Input, type InputVariant } from '../input'; import { useOptionalInputGroupContext } from '../input-group/input-group.context'; import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; import { styles } from './combobox.styles'; -export type ComboboxRootProps = AutocompleteProps; +export type ComboboxRootProps = Omit; export type ComboboxSize = 'sm' | 'md' | 'lg'; export type ComboboxTriggerProps = MosaicComponentProps<'button'>; +export const ComboboxCollection = Autocomplete.Collection; const ComboboxAnchorContext = React.createContext<{ anchor: HTMLElement | null; @@ -28,9 +30,10 @@ export function ComboboxRoot({ sideOffset = 8, ...props }: ComboboxRootProps) { const context = React.useMemo(() => ({ anchor, setAnchor }), [anchor]); return ( - ); @@ -41,7 +44,7 @@ export const ComboboxTrigger = React.forwardRef { /** Overrides positioning against the input group or standalone input. */ - anchor?: React.ComponentPropsWithoutRef['anchor']; + anchor?: React.ComponentPropsWithoutRef['anchor']; /** Container the combobox portals into. Defaults to `document.body`. */ - portalRoot?: React.ComponentPropsWithoutRef['root']; + portalRoot?: React.ComponentPropsWithoutRef['root']; } /** Floating listbox surface. Portal and positioning are handled internally. */ @@ -98,12 +101,12 @@ export const ComboboxPopup = React.forwardRef - + - {children} - - - + + + ); }); @@ -135,7 +138,7 @@ export const ComboboxList = React.forwardRef( ref, ) { return ( - ; +export type ComboboxOptionIndicatorProps = MosaicComponentProps<'span'>; + +export const ComboboxOptionIndicator = React.forwardRef( + function MosaicComboboxOptionIndicator({ className, style, children, ...props }, ref) { + return ( + + {children ?? ( + + )} + + ); + }, +); + export const ComboboxEmpty = React.forwardRef(function MosaicComboboxEmpty( { render, className, style, ...rest }, ref, @@ -186,10 +210,12 @@ export const ComboboxEmpty = React.forwardRef Date: Wed, 9 Sep 2026 17:55:20 -0600 Subject: [PATCH 09/13] docs(swingset): demonstrate combobox selection behavior --- packages/swingset/src/stories/combobox.mdx | 37 +++++++++------ .../swingset/src/stories/combobox.stories.tsx | 45 +++++++++---------- 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index f7b781fce33..466a5a33c55 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -5,17 +5,24 @@ import * as ComboboxStories from './combobox.stories'; `Combobox` adds Mosaic styling to the headless `Autocomplete` behavior: text entry, listbox ARIA, keyboard navigation, selection, positioning, and the shared scrolling treatment. +`value` is the selected option; `inputValue` is the search text. Typing does not change the selection. +Closing without choosing restores the selected label, or clears the input when nothing is selected. +Deleting all text clears the selection and calls `onValueChange(null)`. +Opening with the trigger or arrow keys keeps the selected label visible and shows all options until you edit it. + ## Example Use the trigger to show every option, or type to open and filter the list. Use the arrow keys and Enter to select an option. +`OptionIndicator` shows a check beside the selected option, independently of the hover or keyboard highlight. + -Filtering stays with the caller: read `inputValue` and render only the matching options. +`Combobox.Collection` filters its items using the search query, without treating the selected label as a filter. `Combobox.Popup` owns its portal, positioner, surface, and scrolling viewport. The popup automatically aligns with the surrounding input group, or the input when used alone. Use `Popup`'s `anchor` prop to override this. `Root`'s `sideOffset` defaults to `8`. @@ -25,9 +32,10 @@ Use `Popup`'s `anchor` prop to override this. `Root`'s `sideOffset` defaults to Use `List` when the searchable list already lives in another floating surface, such as a country picker inside a popover. Use the ghost input variant when it needs an icon or adjacent text. This avoids both a second input implementation and a nested popup. +Set `inline` on the root and bind its open state to the outer surface. Closing resets the search, not the selection. ```tsx - + - {countries.map(country => ( + country.name}> + {country => ( {country.name} - ))} + )} + ``` @@ -55,12 +65,13 @@ Long lists receive the shared ScrollArea fade and scrollbar automatically. ## Parts -| Part | Description | -| ------------------ | --------------------------------------------------------------------------------- | -| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. | -| `Combobox.Input` | Autocomplete input; renders Mosaic `Input` unless composed through another input. | -| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. | -| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. | -| `Combobox.List` | Scrollable inline listbox. | -| `Combobox.Option` | Selectable option with active, selected, and disabled states. | -| `Combobox.Empty` | Empty result message. | +| Part | Description | +| --------------------- | --------------------------------------------------------------------------------- | +| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. | +| `Combobox.Input` | Autocomplete input; renders Mosaic `Input` unless composed through another input. | +| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. | +| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. | +| `Combobox.List` | Scrollable inline listbox. | +| `Combobox.Collection` | Filters items and renders each option, with an optional empty state. | +| `Combobox.Option` | Selectable option with active, selected, and disabled states. | +| `Combobox.Empty` | Empty result message. | diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx index c113c1b433a..e6f52de320f 100644 --- a/packages/swingset/src/stories/combobox.stories.tsx +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -5,7 +5,6 @@ import { Combobox } from '@clerk/ui/mosaic/components/combobox'; import { Field } from '@clerk/ui/mosaic/components/field'; import { Icon } from '@clerk/ui/mosaic/components/icon'; import { InputGroup } from '@clerk/ui/mosaic/components/input-group'; -import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -19,15 +18,10 @@ export const meta: StoryMeta = { }; export function Default() { - const [query, setQuery] = useState(''); const options = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Fig', 'Grape', 'Pear', 'Plum']; - const filtered = options.filter(option => option.toLowerCase().includes(query.toLowerCase())); return ( - + Fruit @@ -50,34 +44,32 @@ export function Default() { - {filtered.length > 0 ? ( - filtered.map(option => ( + option} + empty={No fruit found} + > + {option => ( {option} + - )) - ) : ( - No fruit found - )} + )} + ); } export function Scrolling() { - const [query, setQuery] = useState(''); const options = Array.from({ length: 40 }, (_, index) => `Fruit ${index + 1}`); - const filtered = options.filter(option => option.toLowerCase().includes(query.toLowerCase())); return ( - + Fruit @@ -100,19 +92,22 @@ export function Scrolling() { - {filtered.length > 0 ? ( - filtered.map(option => ( + option} + empty={No fruit found} + > + {option => ( {option} + - )) - ) : ( - No fruit found - )} + )} + ); From 47713f15ddd829d7abb32baaedd92b0643bfede6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 18:29:50 -0600 Subject: [PATCH 10/13] refactor(ui): use headless combobox primitive --- packages/swingset/src/stories/combobox.mdx | 22 +++++------ .../mosaic/components/combobox/combobox.tsx | 39 +++++++++---------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index 466a5a33c55..d93d77b35ec 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -2,7 +2,7 @@ import * as ComboboxStories from './combobox.stories'; # Combobox -`Combobox` adds Mosaic styling to the headless `Autocomplete` behavior: text entry, listbox ARIA, +`Combobox` adds Mosaic styling to the headless `Combobox` behavior: text entry, listbox ARIA, keyboard navigation, selection, positioning, and the shared scrolling treatment. `value` is the selected option; `inputValue` is the search text. Typing does not change the selection. @@ -65,13 +65,13 @@ Long lists receive the shared ScrollArea fade and scrollbar automatically. ## Parts -| Part | Description | -| --------------------- | --------------------------------------------------------------------------------- | -| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. | -| `Combobox.Input` | Autocomplete input; renders Mosaic `Input` unless composed through another input. | -| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. | -| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. | -| `Combobox.List` | Scrollable inline listbox. | -| `Combobox.Collection` | Filters items and renders each option, with an optional empty state. | -| `Combobox.Option` | Selectable option with active, selected, and disabled states. | -| `Combobox.Empty` | Empty result message. | +| Part | Description | +| --------------------- | --------------------------------------------------------------------------- | +| `Combobox.Root` | Owns input, selection, open state, and keyboard navigation. | +| `Combobox.Input` | Search input; renders Mosaic `Input` unless composed through another input. | +| `Combobox.Trigger` | Opens and closes the option list while keeping focus on the input. | +| `Combobox.Popup` | Portals, positions, surfaces, and scrolls a floating option list. | +| `Combobox.List` | Scrollable inline listbox. | +| `Combobox.Collection` | Filters items and renders each option, with an optional empty state. | +| `Combobox.Option` | Selectable option with active, selected, and disabled states. | +| `Combobox.Empty` | Empty result message. | diff --git a/packages/ui/src/mosaic/components/combobox/combobox.tsx b/packages/ui/src/mosaic/components/combobox/combobox.tsx index 655ea194a75..d2bc1c4730d 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.tsx +++ b/packages/ui/src/mosaic/components/combobox/combobox.tsx @@ -1,7 +1,7 @@ 'use client'; -import type { AutocompleteProps } from '@clerk/headless/autocomplete'; -import { Autocomplete } from '@clerk/headless/autocomplete'; +import type { ComboboxProps } from '@clerk/headless/combobox'; +import { Combobox as HeadlessCombobox } from '@clerk/headless/combobox'; import { useRender } from '@clerk/headless/utils'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -15,10 +15,10 @@ import { useOptionalInputGroupContext } from '../input-group/input-group.context import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; import { styles } from './combobox.styles'; -export type ComboboxRootProps = Omit; +export type ComboboxRootProps = ComboboxProps; export type ComboboxSize = 'sm' | 'md' | 'lg'; export type ComboboxTriggerProps = MosaicComponentProps<'button'>; -export const ComboboxCollection = Autocomplete.Collection; +export const ComboboxCollection = HeadlessCombobox.Collection; const ComboboxAnchorContext = React.createContext<{ anchor: HTMLElement | null; @@ -30,10 +30,9 @@ export function ComboboxRoot({ sideOffset = 8, ...props }: ComboboxRootProps) { const context = React.useMemo(() => ({ anchor, setAnchor }), [anchor]); return ( - ); @@ -44,7 +43,7 @@ export const ComboboxTrigger = React.forwardRef { /** Overrides positioning against the input group or standalone input. */ - anchor?: React.ComponentPropsWithoutRef['anchor']; + anchor?: React.ComponentPropsWithoutRef['anchor']; /** Container the combobox portals into. Defaults to `document.body`. */ - portalRoot?: React.ComponentPropsWithoutRef['root']; + portalRoot?: React.ComponentPropsWithoutRef['root']; } /** Floating listbox surface. Portal and positioning are handled internally. */ @@ -101,12 +100,12 @@ export const ComboboxPopup = React.forwardRef - + - {children} - - - + + + ); }); @@ -138,7 +137,7 @@ export const ComboboxList = React.forwardRef( ref, ) { return ( - ; export const ComboboxOptionIndicator = React.forwardRef( function MosaicComboboxOptionIndicator({ className, style, children, ...props }, ref) { return ( - )} - + ); }, ); From f2dce79fa64e43487facecfa463e94189ad1252a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 10 Sep 2026 11:27:09 -0600 Subject: [PATCH 11/13] fix(ui): inherit input group variant in combobox --- .../swingset/src/stories/combobox.stories.tsx | 10 ++----- .../components/combobox/combobox.test.tsx | 30 +++++++++++++++++-- .../mosaic/components/combobox/combobox.tsx | 3 +- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx index e6f52de320f..b6717050cb6 100644 --- a/packages/swingset/src/stories/combobox.stories.tsx +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -25,10 +25,7 @@ export function Default() { Fruit - + Fruit - + vo @@ -52,6 +51,32 @@ function FloatingCombobox(props?: { onValueChange?: (value: string | null) => vo } describe('Mosaic Combobox', () => { + it.each([undefined, 'ghost', 'default'] as const)('supports the %s variant outside a group', variant => { + render( + + + , + ); + expect(screen.getByRole('combobox')).toHaveAttribute('data-variant', variant ?? 'default'); + }); + + it('respects an explicit default variant inside a group', () => { + render( + + + + + , + ); + expect(screen.getByRole('combobox')).toHaveAttribute('data-variant', 'default'); + }); + it('removes the check and selection when the input is cleared', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); @@ -290,7 +315,7 @@ describe('Mosaic Combobox', () => { expect(onValueChange).not.toHaveBeenCalled(); }); - it('uses the ghost Input variant inside an input group', () => { + it('defaults to the ghost Input variant inside an input group', () => { render( @@ -301,7 +326,6 @@ describe('Mosaic Combobox', () => { /> diff --git a/packages/ui/src/mosaic/components/combobox/combobox.tsx b/packages/ui/src/mosaic/components/combobox/combobox.tsx index d2bc1c4730d..fc275a1ac7e 100644 --- a/packages/ui/src/mosaic/components/combobox/combobox.tsx +++ b/packages/ui/src/mosaic/components/combobox/combobox.tsx @@ -57,10 +57,11 @@ export interface ComboboxInputProps extends Omit, } export const ComboboxInput = React.forwardRef(function MosaicComboboxInput( - { size: sizeProp, variant = 'default', render, className, style, ...rest }, + { size: sizeProp, variant: variantProp, render, className, style, ...rest }, ref, ) { const inputGroup = useOptionalInputGroupContext(); + const variant = variantProp ?? (inputGroup ? 'ghost' : 'default'); const setAnchor = React.useContext(ComboboxAnchorContext)?.setAnchor; const groupElement = inputGroup?.element; React.useLayoutEffect(() => { From 1247d4d59f4c2d8d3d52d2c9635987efb59b8a79 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 10 Sep 2026 15:52:16 -0600 Subject: [PATCH 12/13] docs(swingset): simplify combobox documentation --- packages/swingset/src/stories/combobox.mdx | 38 +++++----------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx index d93d77b35ec..3e71610de51 100644 --- a/packages/swingset/src/stories/combobox.mdx +++ b/packages/swingset/src/stories/combobox.mdx @@ -2,37 +2,19 @@ import * as ComboboxStories from './combobox.stories'; # Combobox -`Combobox` adds Mosaic styling to the headless `Combobox` behavior: text entry, listbox ARIA, -keyboard navigation, selection, positioning, and the shared scrolling treatment. - -`value` is the selected option; `inputValue` is the search text. Typing does not change the selection. -Closing without choosing restores the selected label, or clears the input when nothing is selected. -Deleting all text clears the selection and calls `onValueChange(null)`. -Opening with the trigger or arrow keys keeps the selected label visible and shows all options until you edit it. - ## Example -Use the trigger to show every option, or type to open and filter the list. Use the arrow keys and -Enter to select an option. - -`OptionIndicator` shows a check beside the selected option, independently of the hover or keyboard highlight. - -`Combobox.Collection` filters its items using the search query, without treating the selected label as a filter. -`Combobox.Popup` owns its portal, positioner, surface, and scrolling viewport. -The popup automatically aligns with the surrounding input group, or the input when used alone. -Use `Popup`'s `anchor` prop to override this. `Root`'s `sideOffset` defaults to `8`. +`value` is the selected option; `inputValue` is the search text. Clearing the input clears the selection. ## Inline lists -Use `List` when the searchable list already lives in another floating surface, such as a country -picker inside a popover. Use the ghost input variant when it needs an icon or adjacent text. -This avoids both a second input implementation and a nested popup. -Set `inline` on the root and bind its open state to the outer surface. Closing resets the search, not the selection. +Use `List` inside an existing popover. Set `inline` and bind the root's open state to that popover +so closing resets the search, not the selection. ```tsx @@ -40,15 +22,15 @@ Set `inline` on the root and bind its open state to the outer surface. Closing r - + country.name}> - {country => ( - - {country.name} - - )} + {country => ( + + {country.name} + + )} @@ -56,8 +38,6 @@ Set `inline` on the root and bind its open state to the outer surface. Closing r ## Scrolling -Long lists receive the shared ScrollArea fade and scrollbar automatically. - Date: Thu, 10 Sep 2026 16:09:05 -0600 Subject: [PATCH 13/13] `refactor(ui): deduplicate combobox logic` --- pnpm-lock.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82eb9aa6e47..b3ecba5aa74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9060,7 +9060,6 @@ packages: eslint@9.31.0: resolution: {integrity: sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*'