From d8d4bf6f76c556797a5cd1efc229a2d817b00d6d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 2 Sep 2026 18:47:31 -0600 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] `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: '*' From 0cc0d3e57bacfd289ae378762f31451b69fe79dd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 2 Sep 2026 18:20:22 -0600 Subject: [PATCH 14/22] feat(ui): add phone input --- .changeset/warm-taxis-call.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 18 + packages/swingset/src/stories/phone-input.mdx | 72 ++++ .../src/stories/phone-input.stories.tsx | 106 ++++++ .../mosaic/components/phone-input/index.ts | 3 + .../phone-input/phone-input.styles.ts | 65 ++++ .../phone-input/phone-input.test.tsx | 204 +++++++++++ .../components/phone-input/phone-input.tsx | 341 ++++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 2 + 10 files changed, 814 insertions(+) create mode 100644 .changeset/warm-taxis-call.md create mode 100644 packages/swingset/src/stories/phone-input.mdx create mode 100644 packages/swingset/src/stories/phone-input.stories.tsx create mode 100644 packages/ui/src/mosaic/components/phone-input/index.ts create mode 100644 packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts create mode 100644 packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx create mode 100644 packages/ui/src/mosaic/components/phone-input/phone-input.tsx diff --git a/.changeset/warm-taxis-call.md b/.changeset/warm-taxis-call.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/warm-taxis-call.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d0b68d9e9fb..784e648e294 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -49,6 +49,7 @@ const docModules: Record> = { combobox: dynamic(() => import('../stories/combobox.mdx')), input: dynamic(() => import('../stories/input.mdx')), 'input-group': dynamic(() => import('../stories/input-group.mdx')), + 'phone-input': dynamic(() => import('../stories/phone-input.mdx')), item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), drawer: dynamic(() => import('../stories/drawer.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index f429147e0c6..ebe2f7b9fab 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -105,6 +105,14 @@ import { Success as OtpComponentSuccess, } from '../stories/otp.component.stories'; import { meta as otpMeta } from '../stories/otp.stories'; +import { + Default as PhoneInputDefault, + Disabled as PhoneInputDisabled, + Invalid as PhoneInputInvalid, + meta as phoneInputMeta, + Prefilled as PhoneInputPrefilled, + Sizes as PhoneInputSizes, +} from '../stories/phone-input.stories'; import { Alignment as PopoverComponentAlignment, Default as PopoverComponentDefault, @@ -320,6 +328,15 @@ const inputGroupModule: StoryModule = { Invalid: InputGroupInvalid, }; +const phoneInputModule: StoryModule = { + meta: phoneInputMeta, + Default: PhoneInputDefault, + Sizes: PhoneInputSizes, + Prefilled: PhoneInputPrefilled, + Disabled: PhoneInputDisabled, + Invalid: PhoneInputInvalid, +}; + const popoverComponentModule: StoryModule = { meta: popoverComponentMeta, Default: PopoverComponentDefault, @@ -571,6 +588,7 @@ export const registry: StoryModule[] = [ flowComponentModule, inputModule, inputGroupModule, + phoneInputModule, itemModule, dialogComponentModule, drawerComponentModule, diff --git a/packages/swingset/src/stories/phone-input.mdx b/packages/swingset/src/stories/phone-input.mdx new file mode 100644 index 00000000000..0900f5bd33b --- /dev/null +++ b/packages/swingset/src/stories/phone-input.mdx @@ -0,0 +1,72 @@ +import * as PhoneInputStories from './phone-input.stories'; + +# PhoneInput + +The `PhoneInput` combines a searchable country picker and native telephone input into one Mosaic field while exposing a normalized E.164 value. + +It composes `InputGroup` for the telephone field and the inline `Combobox` composition for the country search inside its `Popover`. + +## Playground + + + +## Props + + void', default: '—' }, + { name: 'country', type: 'CountryIso', default: '—' }, + { name: 'defaultCountry', type: 'CountryIso', default: "'us'" }, + { name: 'onCountryChange', type: '(country: CountryIso) => void', default: '—' }, + { name: 'countrySearchPlaceholder', type: 'string', default: "'Search country or code'" }, + { name: 'noResultsMessage', type: 'string', default: "'No countries found'" }, + ]} +/> + +`value` and `defaultValue` use E.164. `onValueChange` reports that normalized value while the visible input formats the national number. Control `country` separately when countries share a calling code. + +## Usage + + + +--- + +## Examples + +### Sizes + + + +### Prefilled international number + + + +### Disabled + + + +### Invalid + + diff --git a/packages/swingset/src/stories/phone-input.stories.tsx b/packages/swingset/src/stories/phone-input.stories.tsx new file mode 100644 index 00000000000..ab8c8405221 --- /dev/null +++ b/packages/swingset/src/stories/phone-input.stories.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { Field } from '@clerk/ui/mosaic/components/field'; +import type { PhoneInputProps } from '@clerk/ui/mosaic/components/phone-input'; +import { PhoneInput } from '@clerk/ui/mosaic/components/phone-input'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './phone-input.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'PhoneInput', + source: 'packages/ui/src/mosaic/components/phone-input/phone-input.tsx', + styles: { + _variants: { + size: { sm: {}, md: {}, lg: {} }, + }, + _defaultVariants: { + size: 'md', + }, + }, +}; + +const stackStyles = { + display: 'grid', + gap: 8, + width: 320, +} as const; + +function knobsAsProps(props: Record) { + return props as unknown as PhoneInputProps; +} + +export function Default(props: Record) { + return ( + + Phone number + + We will send a verification code to this number. + + ); +} + +export function Sizes() { + return ( +
+ + + +
+ ); +} + +export function Prefilled() { + return ( + + Phone number + + Paste an international number to update the detected country. + + ); +} + +export function Disabled() { + return ( + + Phone number + + + ); +} + +export function Invalid() { + return ( + + Phone number + + Enter a valid phone number. + + ); +} diff --git a/packages/ui/src/mosaic/components/phone-input/index.ts b/packages/ui/src/mosaic/components/phone-input/index.ts new file mode 100644 index 00000000000..778f5f9c145 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/index.ts @@ -0,0 +1,3 @@ +export { PhoneInput } from './phone-input'; +export type { PhoneInputProps } from './phone-input'; +export type { CountryIso } from '../../../elements/PhoneInput/countryCodeData'; diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts new file mode 100644 index 00000000000..6602da88e34 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts @@ -0,0 +1,65 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + trigger: { + paddingInlineEnd: 0, + paddingInlineStart: space['2.5'], + }, + triggerContent: { + alignItems: 'center', + display: 'flex', + gap: space['0.5'], + }, + flag: { + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: 1, + }, + divider: { + backgroundColor: colorVars['--cl-color-border'], + flexShrink: 0, + height: space['3.5'], + width: '1px', + }, + prefix: { + gap: space['2'], + fontVariantNumeric: 'tabular-nums', + paddingInlineEnd: 0, + paddingInlineStart: space['2'], + }, + control: { + fontVariantNumeric: 'tabular-nums', + paddingInlineStart: space['2'], + }, + popup: { + borderRadius: radiusVars['--cl-radius-lg'], + 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'], + width: '100%', + }, + countrySearch: { + marginInline: space['2'], + flexShrink: 0, + marginBlockEnd: space['1'], + marginBlockStart: space['2'], + }, + optionName: { + overflow: 'hidden', + flexGrow: 1, + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + optionCode: { + color: colorVars['--cl-color-neutral-faded'], + fontVariantNumeric: 'tabular-nums', + }, + checkHidden: { + visibility: 'hidden', + }, +}); diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx new file mode 100644 index 00000000000..045a0020c23 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx @@ -0,0 +1,204 @@ +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 { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; +import { PhoneInput } from './phone-input'; + +const scrollClasses = stylex.props(...scrollAreaViewport()).className?.split(' ') ?? []; +const rootClasses = stylex.props(scrollAreaRoot).className?.split(' ') ?? []; +const viewportOnlyClasses = scrollClasses.filter(name => !rootClasses.includes(name)); + +describe('Mosaic PhoneInput', () => { + it('renders one grouped telephone control with the default country', () => { + render(); + + const input = screen.getByRole('textbox', { name: 'Phone number' }); + expect(input).toHaveAttribute('type', 'tel'); + expect(input).toHaveAttribute('autocomplete', 'tel-national'); + expect(input).toHaveClass('cl-input', 'cl-phone-input-control'); + expect(input).toHaveAttribute('data-variant', 'ghost'); + const countryTrigger = screen.getByRole('button', { name: 'Country, United States' }); + expect(countryTrigger).toHaveClass('cl-input-group-action', 'cl-phone-input-country-trigger'); + expect(countryTrigger).toHaveAttribute('data-size', 'xs'); + expect(countryTrigger).toHaveAttribute('data-variant', 'ghost'); + expect(screen.queryByText('us')).not.toBeInTheDocument(); + expect(document.querySelector('.cl-phone-input-divider')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('+1')).toHaveClass('cl-phone-input-prefix'); + expect(document.querySelector('.cl-phone-input')).toHaveClass('cl-input-group'); + expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-size', 'md'); + }); + + it('emits an E.164 value while displaying the national number', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + await user.type(screen.getByRole('textbox', { name: 'Phone number' }), '202 555 0123'); + + expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveValue('(202) 555-0123'); + expect(onValueChange).toHaveBeenLastCalledWith('+12025550123'); + }); + + it('searches countries, preserves the number, and returns focus after selection', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onCountryChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + const searchInput = screen.getByRole('combobox', { name: 'Search countries' }); + expect(searchInput).toHaveClass('cl-combobox-input', 'cl-input'); + expect(searchInput).toHaveAttribute('data-variant', 'ghost'); + expect(searchInput.closest('.cl-input-group')).toHaveClass('cl-phone-input-country-search'); + await user.type(searchInput, 'Greece'); + expect(screen.getByRole('listbox')).toHaveClass('cl-combobox-list'); + expect(screen.getByRole('option', { name: /Greece/ })).toHaveClass('cl-combobox-option'); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(screen.getByRole('button', { name: 'Country, Greece' })).toBeInTheDocument(); + expect(screen.getByText('+30')).toHaveClass('cl-phone-input-prefix'); + expect(onCountryChange).toHaveBeenCalledWith('gr'); + expect(onValueChange).toHaveBeenLastCalledWith('+302025550123'); + expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveFocus(); + }); + + it('uses the shared ScrollArea treatment for the country list', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + + const popup = document.querySelector('.cl-phone-input-popup'); + const list = screen.getByRole('listbox'); + expect(popup).toBeInTheDocument(); + expect(list).toHaveClass(...rootClasses, ...scrollClasses); + expect(viewportOnlyClasses).not.toHaveLength(0); + expect(viewportOnlyClasses.filter(name => popup?.classList.contains(name))).toEqual([]); + }); + + it('keeps the country search in its own Field scope', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const user = userEvent.setup(); + render( + + Phone number + + , + ); + + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + + expect(screen.getByRole('combobox', { name: 'Search countries' })).toBeInTheDocument(); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('supports a single form control')); + warn.mockRestore(); + }); + + it('parses a pasted international number', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + const input = screen.getByRole('textbox', { name: 'Phone number' }); + await user.click(input); + await user.paste('+30 690 123 4567'); + + expect(screen.getByRole('button', { name: 'Country, Greece' })).toBeInTheDocument(); + expect(input).toHaveValue('690 1234567'); + expect(onValueChange).toHaveBeenLastCalledWith('+306901234567'); + }); + + it('submits the normalized value through a hidden input', async () => { + const user = userEvent.setup(); + render( +
+ + , + ); + + await user.type(screen.getByRole('textbox', { name: 'Phone number' }), '2025550123'); + + const form = screen.getByTestId('form'); + if (!(form instanceof HTMLFormElement)) { + throw new Error('Expected a form element'); + } + expect(new FormData(form).get('phoneNumber')).toBe('+12025550123'); + }); + + it('inherits Field state and associates its label and messages with the telephone input', () => { + render( + + Phone number + + Enter a valid phone number + , + ); + + const input = screen.getByRole('textbox', { name: 'Phone number' }); + expect(input).toBeDisabled(); + expect(input).toBeRequired(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription('Enter a valid phone number'); + expect(screen.getByRole('button', { name: 'Country, United States' })).toBeDisabled(); + expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-disabled', ''); + expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-invalid', ''); + }); + + it.each(['sm', 'md', 'lg'] as const)('reflects the %s size', size => { + render( + , + ); + + expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-size', size); + }); + + it('supports controlled values', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + const input = screen.getByRole('textbox', { name: 'Phone number' }); + expect(input).toHaveValue('(202) 555-0123'); + + await user.type(input, '4'); + + expect(onValueChange).toHaveBeenLastCalledWith('+120255501234'); + expect(input).toHaveValue('(202) 555-0123'); + }); +}); diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx new file mode 100644 index 00000000000..fcf597eda1c --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx @@ -0,0 +1,341 @@ +'use client'; + +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { CountryEntry, CountryIso } from '../../../elements/PhoneInput/countryCodeData'; +import { IsoToCountryMap } from '../../../elements/PhoneInput/countryCodeData'; +import { + extractDigits, + formatPhoneNumber, + getFlagEmojiFromCountryIso, + parsePhoneString, +} from '../../../utils/phoneUtils'; +import type { MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Combobox } from '../combobox'; +import { Field } from '../field'; +import { useOptionalFieldContext } from '../field/field.context'; +import { Icon } from '../icon'; +import { Input } from '../input'; +import { InputGroup } from '../input-group'; +import { Popover } from '../popover'; +import { styles } from './phone-input.styles'; + +const countryOptions = [...IsoToCountryMap.values()]; + +function getCountry(iso: CountryIso | undefined): CountryEntry { + const country = iso ? IsoToCountryMap.get(iso) : undefined; + const fallback = IsoToCountryMap.get('us') ?? countryOptions[0]; + if (!fallback) { + throw new Error('PhoneInput requires at least one country'); + } + return country ?? fallback; +} + +function getInitialCountry(value: string | undefined, defaultCountry: CountryIso | undefined): CountryIso { + return value ? parsePhoneString(value).iso : getCountry(defaultCountry).iso; +} + +function getNationalNumber(value: string, country: CountryEntry): string { + const digits = extractDigits(value); + return digits.startsWith(country.code) ? digits.slice(country.code.length) : digits; +} + +function toE164(country: CountryEntry, nationalNumber: string): string { + const number = extractDigits(nationalNumber); + return number ? `+${country.code}${number}` : ''; +} + +export interface PhoneInputProps extends Omit< + MosaicElementProps<'input'>, + 'className' | 'style' | 'type' | 'size' | 'value' | 'defaultValue' | 'onChange' +> { + /** The normalized E.164 value. */ + value?: string; + /** The initial normalized E.164 value for an uncontrolled input. */ + defaultValue?: string; + /** Called with the normalized E.164 value whenever the number or country changes. */ + onValueChange?: (value: string) => void; + /** Controls the selected country independently when calling codes are ambiguous. */ + country?: CountryIso; + /** Initial country when neither `country` nor a phone number selects one. @default 'us' */ + defaultCountry?: CountryIso; + onCountryChange?: (country: CountryIso) => void; + size?: 'sm' | 'md' | 'lg'; + countrySearchPlaceholder?: string; + noResultsMessage?: string; + /** Applied to the grouped root. */ + className?: string; + /** Applied to the grouped root. */ + style?: React.CSSProperties; +} + +export const PhoneInput = React.forwardRef(function MosaicPhoneInput( + { + value: valueProp, + defaultValue = '', + onValueChange, + country: countryProp, + defaultCountry, + onCountryChange, + size = 'md', + countrySearchPlaceholder = 'Search country or code', + noResultsMessage = 'No countries found', + disabled: disabledProp, + required: requiredProp, + id, + name, + form, + autoComplete = 'tel-national', + inputMode = 'tel', + maxLength = 25, + spellCheck = false, + className, + style, + 'aria-invalid': ariaInvalidProp, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + ...inputProps + }, + forwardedRef, +) { + const field = useOptionalFieldContext(); + const disabled = disabledProp ?? field?.disabled ?? false; + const ariaInvalid = ariaInvalidProp ?? (field?.invalid ? true : undefined); + const invalid = ariaInvalid === true || ariaInvalid === 'true'; + const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue); + const value = valueProp ?? uncontrolledValue; + const [uncontrolledCountry, setUncontrolledCountry] = React.useState(() => + getInitialCountry(valueProp ?? defaultValue, defaultCountry), + ); + const country = getCountry(countryProp ?? uncontrolledCountry); + const nationalNumber = getNationalNumber(value, country); + const formattedNumber = formatPhoneNumber(nationalNumber, country.pattern, country.code); + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(''); + const inputRef = React.useRef(null); + const setInputRef = React.useCallback( + (node: HTMLInputElement | null) => { + inputRef.current = node; + if (typeof forwardedRef === 'function') { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + React.useEffect(() => { + if (countryProp === undefined && valueProp) { + setUncontrolledCountry(parsePhoneString(valueProp).iso); + } + }, [countryProp, valueProp]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + } + }, [open]); + + const filteredCountries = React.useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return countryOptions; + } + return countryOptions.filter(option => + `${option.name} ${option.iso} +${option.code}`.toLowerCase().includes(normalizedQuery), + ); + }, [query]); + + const setValue = React.useCallback( + (nextValue: string) => { + if (valueProp === undefined) { + setUncontrolledValue(nextValue); + } + onValueChange?.(nextValue); + }, + [onValueChange, valueProp], + ); + + const setCountry = React.useCallback( + (nextCountry: CountryEntry) => { + if (countryProp === undefined) { + setUncontrolledCountry(nextCountry.iso); + } + onCountryChange?.(nextCountry.iso); + setValue(toE164(nextCountry, nationalNumber)); + setOpen(false); + inputRef.current?.focus(); + }, + [countryProp, nationalNumber, onCountryChange, setValue], + ); + + const handleNumberChange = (event: React.ChangeEvent) => { + const nextValue = event.target.value; + if (nextValue.includes('+')) { + const parsed = parsePhoneString(nextValue); + const parsedCountry = getCountry(parsed.iso); + if (countryProp === undefined) { + setUncontrolledCountry(parsedCountry.iso); + } + onCountryChange?.(parsedCountry.iso); + setValue(toE164(parsedCountry, parsed.number)); + return; + } + setValue(toE164(country, nextValue)); + }; + + return ( + <> + + + + } + type='button' + disabled={disabled} + aria-label={`Country, ${country.name}`} + > + + + + + + { + const nextCountry = countryOptions.find(option => option.iso === iso); + if (nextCountry) { + setCountry(nextCountry); + } + }} + > + + + + + + + + + {filteredCountries.length > 0 ? ( + filteredCountries.map(option => ( + + + {option.name} + +{option.code} + + )) + ) : ( + {noResultsMessage} + )} + + + + + + + + + {name ? ( + + ) : null} + + ); +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 2943f42f266..6182d413050 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -64,6 +64,8 @@ export { Input } from '../components/input'; export type { InputProps, InputVariant } from '../components/input'; export { InputGroup } from '../components/input-group'; export type { InputGroupAddonProps, InputGroupRootProps } from '../components/input-group'; +export { PhoneInput } from '../components/phone-input'; +export type { CountryIso, PhoneInputProps } from '../components/phone-input'; export { Item } from '../components/item'; export type { ItemProps } from '../components/item'; export { Menu } from '../components/menu'; From 754c49169fc43983258fe2088ad1d56a4fe8ce3f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 09:40:20 -0600 Subject: [PATCH 15/22] fix(ui): compose phone input with input group slots --- .../phone-input/phone-input.styles.ts | 7 +-- .../phone-input/phone-input.test.tsx | 18 ++++++- .../components/phone-input/phone-input.tsx | 48 +++++++++---------- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts index 6602da88e34..96d1a154fc2 100644 --- a/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts @@ -4,13 +4,14 @@ import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex export const styles = stylex.create({ trigger: { - paddingInlineEnd: 0, - paddingInlineStart: space['2.5'], + paddingInlineEnd: space['2'], + paddingInlineStart: space['2'], + width: 'auto', }, triggerContent: { + gap: space['0.5'], alignItems: 'center', display: 'flex', - gap: space['0.5'], }, flag: { fontSize: typeScaleVars['--cl-text-sm-size'], diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx index 045a0020c23..ccd0ae10b30 100644 --- a/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx @@ -13,6 +13,19 @@ const rootClasses = stylex.props(scrollAreaRoot).className?.split(' ') ?? []; const viewportOnlyClasses = scrollClasses.filter(name => !rootClasses.includes(name)); describe('Mosaic PhoneInput', () => { + it.each([ + ['sm', 'xs'], + ['md', 'sm'], + ['lg', 'md'], + ] as const)('sizes the country trigger for a %s input', (size, triggerSize) => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Country, United States' })).toHaveAttribute('data-size', triggerSize); + }); it('renders one grouped telephone control with the default country', () => { render(); @@ -22,8 +35,9 @@ describe('Mosaic PhoneInput', () => { expect(input).toHaveClass('cl-input', 'cl-phone-input-control'); expect(input).toHaveAttribute('data-variant', 'ghost'); const countryTrigger = screen.getByRole('button', { name: 'Country, United States' }); - expect(countryTrigger).toHaveClass('cl-input-group-action', 'cl-phone-input-country-trigger'); - expect(countryTrigger).toHaveAttribute('data-size', 'xs'); + expect(countryTrigger).toHaveClass('cl-button', 'cl-phone-input-country-trigger'); + expect(countryTrigger.closest('.cl-input-group-start')).not.toBeNull(); + expect(countryTrigger).toHaveAttribute('data-size', 'sm'); expect(countryTrigger).toHaveAttribute('data-variant', 'ghost'); expect(screen.queryByText('us')).not.toBeInTheDocument(); expect(document.querySelector('.cl-phone-input-divider')).toHaveAttribute('aria-hidden', 'true'); diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx index fcf597eda1c..199287220d5 100644 --- a/packages/ui/src/mosaic/components/phone-input/phone-input.tsx +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx @@ -14,6 +14,7 @@ import { import type { MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; +import { Button } from '../button'; import { Combobox } from '../combobox'; import { Field } from '../field'; import { useOptionalFieldContext } from '../field/field.context'; @@ -201,31 +202,30 @@ export const PhoneInput = React.forwardRef(fu onOpenChange={setOpen} placement='bottom-start' > - - } - type='button' - disabled={disabled} - aria-label={`Country, ${country.name}`} - > - - - + + Date: Wed, 9 Sep 2026 12:24:56 -0600 Subject: [PATCH 16/22] docs(swingset): mark phone input as work in progress --- packages/swingset/src/stories/phone-input.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/swingset/src/stories/phone-input.stories.tsx b/packages/swingset/src/stories/phone-input.stories.tsx index ab8c8405221..8ca71a9dd25 100644 --- a/packages/swingset/src/stories/phone-input.stories.tsx +++ b/packages/swingset/src/stories/phone-input.stories.tsx @@ -12,6 +12,7 @@ export { default as __source } from './phone-input.stories?raw'; export const meta: StoryMeta = { group: 'Components', + status: 'wip', title: 'PhoneInput', source: 'packages/ui/src/mosaic/components/phone-input/phone-input.tsx', styles: { From e4a6c29daf7be8779858a780958057124d49d7f1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 14:18:13 -0600 Subject: [PATCH 17/22] test(ui): remove phone input CSS assertions --- .../components/phone-input/phone-input.test.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx index ccd0ae10b30..e6fdd17dcb9 100644 --- a/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx @@ -1,17 +1,11 @@ -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 { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; import { PhoneInput } from './phone-input'; -const scrollClasses = stylex.props(...scrollAreaViewport()).className?.split(' ') ?? []; -const rootClasses = stylex.props(scrollAreaRoot).className?.split(' ') ?? []; -const viewportOnlyClasses = scrollClasses.filter(name => !rootClasses.includes(name)); - describe('Mosaic PhoneInput', () => { it.each([ ['sm', 'xs'], @@ -92,7 +86,7 @@ describe('Mosaic PhoneInput', () => { expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveFocus(); }); - it('uses the shared ScrollArea treatment for the country list', async () => { + it('renders the country list inside the popup', async () => { const user = userEvent.setup(); render(); @@ -101,9 +95,7 @@ describe('Mosaic PhoneInput', () => { const popup = document.querySelector('.cl-phone-input-popup'); const list = screen.getByRole('listbox'); expect(popup).toBeInTheDocument(); - expect(list).toHaveClass(...rootClasses, ...scrollClasses); - expect(viewportOnlyClasses).not.toHaveLength(0); - expect(viewportOnlyClasses.filter(name => popup?.classList.contains(name))).toEqual([]); + expect(popup).toContainElement(list); }); it('keeps the country search in its own Field scope', async () => { From dcb5eacfadd7408f7cb463d0e511149e9977b9a2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 9 Sep 2026 15:49:02 -0600 Subject: [PATCH 18/22] refactor(ui): use current input group parts in phone input --- .../src/mosaic/components/phone-input/phone-input.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx index 199287220d5..e1f7e11fb54 100644 --- a/packages/ui/src/mosaic/components/phone-input/phone-input.tsx +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx @@ -19,7 +19,6 @@ import { Combobox } from '../combobox'; import { Field } from '../field'; import { useOptionalFieldContext } from '../field/field.context'; import { Icon } from '../icon'; -import { Input } from '../input'; import { InputGroup } from '../input-group'; import { Popover } from '../popover'; import { styles } from './phone-input.styles'; @@ -248,13 +247,13 @@ export const PhoneInput = React.forwardRef(fu size='md' {...mergeStyleProps(themeProps('phone-input-country-search'), stylex.props(styles.countrySearch))} > - + + (fu - - + Date: Thu, 10 Sep 2026 01:52:53 -0600 Subject: [PATCH 19/22] fix(ui): refine phone input layout and popup anchoring --- .../primitives/popover/popover-positioner.tsx | 15 ++++++++-- .../src/mosaic/components/button/button.tsx | 4 +-- .../phone-input/phone-input.styles.ts | 25 +++++++++-------- .../phone-input/phone-input.test.tsx | 28 +++++++++++++------ .../components/phone-input/phone-input.tsx | 25 +++++++++++++---- .../components/popover/popover.styles.ts | 1 + .../components/popover/popover.test.tsx | 27 +++++++++++++++++- .../src/mosaic/components/popover/popover.tsx | 7 +++-- 8 files changed, 99 insertions(+), 33 deletions(-) diff --git a/packages/headless/src/primitives/popover/popover-positioner.tsx b/packages/headless/src/primitives/popover/popover-positioner.tsx index b9aff77ad6a..0c8ac490320 100644 --- a/packages/headless/src/primitives/popover/popover-positioner.tsx +++ b/packages/headless/src/primitives/popover/popover-positioner.tsx @@ -6,11 +6,14 @@ import React from 'react'; import { type ComponentProps, type DefaultProps, isKeyboardOpen, mergeProps, useRender } from '../../utils'; import { usePopoverContext } from './popover-context'; -export type PopoverPositionerProps = ComponentProps<'div'>; +export interface PopoverPositionerProps extends ComponentProps<'div'> { + /** Positions against this element instead of the trigger. */ + anchor?: HTMLElement | null; +} export const PopoverPositioner = React.forwardRef( function PopoverPositioner(props, ref) { - const { render, ...otherProps } = props; + const { anchor, render, ...otherProps } = props; const { mounted, floatingContext, @@ -27,6 +30,14 @@ export const PopoverPositioner = React.forwardRef { + if (!anchor) { + return; + } + refs.setPositionReference(anchor); + return () => refs.setPositionReference(refs.domReference.current); + }, [anchor, refs]); + const side = placement.split('-')[0]; const ownProps = { diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index 039bfb1ebce..5ed6e0f63e9 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -139,8 +139,8 @@ export const Button = React.forwardRef(function isIconShape && iconSizes[size], hasTouchTarget && styles.touchTarget, hasTouchTarget && isIconShape && styles.touchTargetIcon, - sizeProp === undefined && defaults.sizeStyles, - sizeProp === undefined && isIconShape && defaults.iconStyles, + defaults.sizeStyles, + isIconShape && defaults.iconStyles, fullWidth && styles.fullWidth, disabled && styles.disabled, defaults.styles, diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts index 96d1a154fc2..75c1e548a81 100644 --- a/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts @@ -3,11 +3,6 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; export const styles = stylex.create({ - trigger: { - paddingInlineEnd: space['2'], - paddingInlineStart: space['2'], - width: 'auto', - }, triggerContent: { gap: space['0.5'], alignItems: 'center', @@ -17,6 +12,15 @@ export const styles = stylex.create({ fontSize: typeScaleVars['--cl-text-sm-size'], lineHeight: 1, }, + triggerFlag: { + alignItems: 'center', + display: 'flex', + flexShrink: 0, + fontSize: space['4'], + justifyContent: 'center', + height: space['4'], + width: space['4'], + }, divider: { backgroundColor: colorVars['--cl-color-border'], flexShrink: 0, @@ -26,8 +30,6 @@ export const styles = stylex.create({ prefix: { gap: space['2'], fontVariantNumeric: 'tabular-nums', - paddingInlineEnd: 0, - paddingInlineStart: space['2'], }, control: { fontVariantNumeric: 'tabular-nums', @@ -41,13 +43,12 @@ export const styles = stylex.create({ 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'], - width: '100%', }, - countrySearch: { - marginInline: space['2'], + countrySearchContainer: { + paddingInline: space['2'], flexShrink: 0, - marginBlockEnd: space['1'], - marginBlockStart: space['2'], + paddingBlockEnd: space['1'], + paddingBlockStart: space['2'], }, optionName: { overflow: 'hidden', diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx index e6fdd17dcb9..62c1a01a2d8 100644 --- a/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.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'; @@ -7,18 +7,30 @@ import { Field } from '../field'; import { PhoneInput } from './phone-input'; describe('Mosaic PhoneInput', () => { - it.each([ - ['sm', 'xs'], - ['md', 'sm'], - ['lg', 'md'], - ] as const)('sizes the country trigger for a %s input', (size, triggerSize) => { + it('positions the country popup against the full phone field', async () => { + const user = userEvent.setup(); + render(); + const group = screen.getByRole('textbox', { name: 'Phone number' }).closest('.cl-phone-input'); + if (!group) { + throw new Error('Phone input group missing'); + } + const measureGroup = vi.spyOn(group, 'getBoundingClientRect'); + + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + + await waitFor(() => expect(measureGroup).toHaveBeenCalled()); + expect(document.querySelector('.cl-phone-input-popup')).toHaveAttribute('data-size', 'anchor'); + }); + + it.each(['sm', 'md', 'lg'] as const)('uses an xs country trigger for a %s input', size => { render( , ); - expect(screen.getByRole('button', { name: 'Country, United States' })).toHaveAttribute('data-size', triggerSize); + expect(screen.getByRole('button', { name: 'Country, United States' })).toHaveAttribute('data-size', 'xs'); + expect(screen.getByRole('button', { name: 'Country, United States' })).toHaveAttribute('data-shape', 'default'); }); it('renders one grouped telephone control with the default country', () => { render(); @@ -31,7 +43,7 @@ describe('Mosaic PhoneInput', () => { const countryTrigger = screen.getByRole('button', { name: 'Country, United States' }); expect(countryTrigger).toHaveClass('cl-button', 'cl-phone-input-country-trigger'); expect(countryTrigger.closest('.cl-input-group-start')).not.toBeNull(); - expect(countryTrigger).toHaveAttribute('data-size', 'sm'); + expect(countryTrigger).toHaveAttribute('data-size', 'xs'); expect(countryTrigger).toHaveAttribute('data-variant', 'ghost'); expect(screen.queryByText('us')).not.toBeInTheDocument(); expect(document.querySelector('.cl-phone-input-divider')).toHaveAttribute('aria-hidden', 'true'); diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx index e1f7e11fb54..3a096f16200 100644 --- a/packages/ui/src/mosaic/components/phone-input/phone-input.tsx +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx @@ -13,6 +13,7 @@ import { } from '../../../utils/phoneUtils'; import type { MosaicElementProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { colorVars, space } from '../../tokens.stylex'; import { reset } from '../../utils/reset.styles'; import { Button } from '../button'; import { Combobox } from '../combobox'; @@ -114,6 +115,7 @@ export const PhoneInput = React.forwardRef(fu const nationalNumber = getNationalNumber(value, country); const formattedNumber = formatPhoneNumber(nationalNumber, country.pattern, country.code); const [open, setOpen] = React.useState(false); + const [anchor, setAnchor] = React.useState(null); const [query, setQuery] = React.useState(''); const inputRef = React.useRef(null); const setInputRef = React.useCallback( @@ -191,6 +193,7 @@ export const PhoneInput = React.forwardRef(fu return ( <> (fu open={open} onOpenChange={setOpen} placement='bottom-start' + sideOffset={8} > +