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/.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/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/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index e1392f4f947..784e648e294 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -46,8 +46,10 @@ 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')), + '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 c1f60a6e2b8..ebe2f7b9fab 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, @@ -100,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, @@ -266,6 +279,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, @@ -309,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, @@ -556,9 +584,11 @@ export const registry: StoryModule[] = [ bannerModule, buttonModule, cardComponentModule, + comboboxModule, flowComponentModule, inputModule, inputGroupModule, + phoneInputModule, itemModule, dialogComponentModule, drawerComponentModule, diff --git a/packages/swingset/src/stories/combobox.mdx b/packages/swingset/src/stories/combobox.mdx new file mode 100644 index 00000000000..3e71610de51 --- /dev/null +++ b/packages/swingset/src/stories/combobox.mdx @@ -0,0 +1,57 @@ +import * as ComboboxStories from './combobox.stories'; + +# Combobox + +## Example + + + +`value` is the selected option; `inputValue` is the search text. Clearing the input clears the selection. + +## Inline lists + +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 + + + + + + + + country.name}> + {country => ( + + {country.name} + + )} + + + +``` + +## Scrolling + + + +## Parts + +| 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/swingset/src/stories/combobox.stories.tsx b/packages/swingset/src/stories/combobox.stories.tsx new file mode 100644 index 00000000000..b6717050cb6 --- /dev/null +++ b/packages/swingset/src/stories/combobox.stories.tsx @@ -0,0 +1,108 @@ +'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'; +import { InputGroup } from '@clerk/ui/mosaic/components/input-group'; + +import type { StoryMeta } from '@/lib/types'; + +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', +}; + +export function Default() { + const options = ['Apple', 'Apricot', 'Banana', 'Blackberry', 'Cherry', 'Fig', 'Grape', 'Pear', 'Plum']; + + return ( + + + Fruit + + + + } + > + + + + + + option} + empty={No fruit found} + > + {option => ( + + {option} + + + )} + + + + ); +} + +export function Scrolling() { + const options = Array.from({ length: 40 }, (_, index) => `Fruit ${index + 1}`); + + return ( + + + Fruit + + + + } + > + + + + + + option} + empty={No fruit found} + > + {option => ( + + {option} + + + )} + + + + ); +} 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..8ca71a9dd25 --- /dev/null +++ b/packages/swingset/src/stories/phone-input.stories.tsx @@ -0,0 +1,107 @@ +'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', + status: 'wip', + 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/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/combobox/combobox.styles.ts b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts new file mode 100644 index 00000000000..31fda1947b4 --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.styles.ts @@ -0,0 +1,98 @@ +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', + }, + 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 new file mode 100644 index 00000000000..e31df084c4a --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.test.tsx @@ -0,0 +1,351 @@ +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'; + +import { Button } from '../button'; +import { Field } from '../field'; +import { Icon } from '../icon'; +import { InputGroup } from '../input-group'; +import { Combobox } from './combobox'; + +function FloatingCombobox(props?: { onValueChange?: (value: string | null) => void; anchor?: HTMLElement }) { + return ( + + + + + } + > + + + + + + Apple + + + + Banana + + + + + ); +} + +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(); + 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(); + 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(); + + const input = screen.getByRole('combobox', { name: 'Fruit' }); + const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); + 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'); + + await user.click(trigger); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(trigger).toHaveAttribute('data-open', ''); + 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( + + 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 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).toBeInTheDocument(); + 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('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(); + 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('defaults to 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'); + 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..fc275a1ac7e --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/combobox.tsx @@ -0,0 +1,221 @@ +'use client'; + +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'; + +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 = ComboboxProps; +export type ComboboxSize = 'sm' | 'md' | 'lg'; +export type ComboboxTriggerProps = MosaicComponentProps<'button'>; +export const ComboboxCollection = HeadlessCombobox.Collection; + +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, +) { + return ( + + ); +}); + +export interface ComboboxInputProps extends Omit, 'size'> { + size?: ComboboxSize; + variant?: InputVariant; +} + +export const ComboboxInput = React.forwardRef(function MosaicComboboxInput( + { 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(() => { + setAnchor?.(groupElement ?? null); + return () => setAnchor?.(null); + }, [groupElement, setAnchor]); + const size = inputGroup?.size ?? sizeProp ?? 'md'; + + return ( + + ) + } + {...mergeStyleProps(themeProps('combobox-input', { size, variant }), className, style)} + {...rest} + /> + ); +}); + +export interface ComboboxPopupProps extends MosaicComponentProps<'div'> { + /** 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( + { anchor, portalRoot, className, style, children, ...rest }, + ref, +) { + const context = React.useContext(ComboboxAnchorContext); + 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 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, +) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps(themeProps('combobox-empty'), stylex.props(reset.base, styles.empty), className, style), + ...rest, + }, + }); +}); + +export const Combobox = { + Root: ComboboxRoot, + Collection: ComboboxCollection, + Input: ComboboxInput, + Trigger: ComboboxTrigger, + Popup: ComboboxPopup, + List: ComboboxList, + Option: ComboboxOption, + OptionIndicator: ComboboxOptionIndicator, + 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..ffe2c0b2646 --- /dev/null +++ b/packages/ui/src/mosaic/components/combobox/index.ts @@ -0,0 +1,23 @@ +export { + Combobox, + ComboboxCollection, + ComboboxEmpty, + ComboboxInput, + ComboboxList, + ComboboxOption, + ComboboxOptionIndicator, + ComboboxPopup, + ComboboxRoot, + ComboboxTrigger, +} from './combobox'; +export type { + ComboboxEmptyProps, + ComboboxInputProps, + ComboboxListProps, + ComboboxOptionProps, + ComboboxOptionIndicatorProps, + ComboboxPopupProps, + ComboboxRootProps, + ComboboxSize, + ComboboxTriggerProps, +} from './combobox'; diff --git a/packages/ui/src/mosaic/components/input-group/input-group.context.ts b/packages/ui/src/mosaic/components/input-group/input-group.context.ts index 2861a28f43c..99d78b3326d 100644 --- a/packages/ui/src/mosaic/components/input-group/input-group.context.ts +++ b/packages/ui/src/mosaic/components/input-group/input-group.context.ts @@ -5,6 +5,7 @@ import React from 'react'; export type InputGroupSize = 'sm' | 'md' | 'lg'; interface InputGroupContextValue { + element: HTMLDivElement | null; disabled: boolean; invalid: boolean; inputRef: (node: HTMLInputElement | null) => 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 }), 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..d1978669800 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts @@ -0,0 +1,71 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + triggerContent: { + gap: space['0.5'], + alignItems: 'center', + display: 'flex', + }, + flag: { + 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, + height: space['3.5'], + width: '1px', + }, + prefix: { + gap: space['2'], + fontVariantNumeric: 'tabular-nums', + }, + 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'], + }, + countrySearchContainer: { + paddingInline: space['2'], + flexShrink: 0, + paddingBlockEnd: space['1'], + paddingBlockStart: space['2'], + }, + optionName: { + overflow: 'hidden', + flexGrow: 1, + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + optionCode: { + color: colorVars['--cl-color-neutral-faded'], + flexShrink: 0, + fontVariantNumeric: 'tabular-nums', + whiteSpace: 'nowrap', + }, + indicatorSlot: { + display: 'flex', + flexShrink: 0, + width: space['3'], + }, +}); 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..8743248f769 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx @@ -0,0 +1,273 @@ +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'; + +import { Field } from '../field'; +import { PhoneInput } from './phone-input'; + +describe('Mosaic PhoneInput', () => { + it('focuses the country search when opened with a pointer', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveFocus()); + }); + + it('forwards object refs and clears them on unmount', () => { + const ref = React.createRef(); + const { unmount } = render( + , + ); + expect(ref.current).toBe(screen.getByRole('textbox')); + unmount(); + expect(ref.current).toBeNull(); + }); + + it('forwards callback refs and clears them on unmount', () => { + const ref = vi.fn(); + const { unmount } = render( + , + ); + expect(ref).toHaveBeenLastCalledWith(screen.getByRole('textbox')); + unmount(); + expect(ref).toHaveBeenLastCalledWith(null); + }); + + it('keeps the country indicator on the selection while hovering another country', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + const us = screen.getByRole('option', { name: /United States/ }); + const uk = screen.getByRole('option', { name: /United Kingdom/ }); + await user.hover(uk); + expect(us.querySelector('.cl-combobox-option-indicator')).toBeVisible(); + expect(uk.querySelector('.cl-combobox-option-indicator')).not.toBeInTheDocument(); + await user.click(uk); + await user.click(screen.getByRole('button', { name: 'Country, United Kingdom' })); + expect( + screen.getByRole('option', { name: /United Kingdom/ }).querySelector('.cl-combobox-option-indicator'), + ).toBeVisible(); + }); + + 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', 'xs'); + expect(screen.getByRole('button', { name: 'Country, United States' })).toHaveAttribute('data-shape', 'default'); + }); + 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-button', 'cl-phone-input-country-trigger'); + expect(countryTrigger.closest('.cl-input-group-start')).not.toBeNull(); + 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('renders the country list inside the popup', 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(popup).toContainElement(list); + }); + + 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..3071c5a0b01 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx @@ -0,0 +1,340 @@ +'use client'; + +import { useMergeRefs } from '@floating-ui/react'; +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 { colorVars, space } from '../../tokens.stylex'; +import { reset } from '../../utils/reset.styles'; +import { Button } from '../button'; +import { Combobox } from '../combobox'; +import { Field } from '../field'; +import { useOptionalFieldContext } from '../field/field.context'; +import { Icon } from '../icon'; +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 [anchor, setAnchor] = React.useState(null); + const [query, setQuery] = React.useState(''); + const inputRef = React.useRef(null); + const mergedInputRef = useMergeRefs([inputRef, 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/components/popover/popover.styles.ts b/packages/ui/src/mosaic/components/popover/popover.styles.ts index 2805d5384cb..2703d2fb53d 100644 --- a/packages/ui/src/mosaic/components/popover/popover.styles.ts +++ b/packages/ui/src/mosaic/components/popover/popover.styles.ts @@ -92,6 +92,7 @@ export const styles = stylex.create({ // `md` reproduces the width the legacy `PopoverCard` uses (`theme.sizes.$94`), so // popovers migrating onto Mosaic keep their current footprint. export const sizes = stylex.create({ + anchor: { width: 'var(--cl-anchor-width)' }, sm: { width: 'min(18rem, calc(100vw - 2rem))' }, md: { width: 'min(23.5rem, calc(100vw - 2rem))' }, lg: { width: 'min(26rem, calc(100vw - 2rem))' }, diff --git a/packages/ui/src/mosaic/components/popover/popover.test.tsx b/packages/ui/src/mosaic/components/popover/popover.test.tsx index 68abbd3cd22..3933ca72ed9 100644 --- a/packages/ui/src/mosaic/components/popover/popover.test.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -14,6 +14,31 @@ const settle = () => }); describe('Mosaic Popover', () => { + it('uses a custom anchor and restores the trigger when it is removed', async () => { + const anchor = document.createElement('div'); + const measureAnchor = vi.spyOn(anchor, 'getBoundingClientRect'); + const example = (element: HTMLElement | null) => ( + + Open + + Body + + + ); + const { rerender } = render(example(anchor)); + await waitFor(() => expect(measureAnchor).toHaveBeenCalled()); + + const measureTrigger = vi.spyOn(screen.getByRole('button', { name: 'Open' }), 'getBoundingClientRect'); + measureAnchor.mockClear(); + rerender(example(null)); + await waitFor(() => expect(measureTrigger).toHaveBeenCalled()); + expect(measureAnchor).not.toHaveBeenCalled(); + }); + it('renders the trigger and opens the popup on click', async () => { const user = userEvent.setup(); render( diff --git a/packages/ui/src/mosaic/components/popover/popover.tsx b/packages/ui/src/mosaic/components/popover/popover.tsx index 0870f8c2310..d2995fbf544 100644 --- a/packages/ui/src/mosaic/components/popover/popover.tsx +++ b/packages/ui/src/mosaic/components/popover/popover.tsx @@ -9,7 +9,7 @@ import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; import { sizes, styles } from './popover.styles'; -export type PopoverSize = 'sm' | 'md' | 'lg'; +export type PopoverSize = 'sm' | 'md' | 'lg' | 'anchor'; export type PopoverRootProps = HeadlessPopoverProps; @@ -107,6 +107,8 @@ function Positioner({ children, ...rest }: React.ComponentPropsWithoutRef { + /** Positions against this element instead of the trigger. */ + anchor?: HTMLElement | null; /** Width of the floating box. */ size?: PopoverSize; /** @@ -123,7 +125,7 @@ export interface PopoverPopupProps extends MosaicComponentProps<'div'> { * `Popover.Trigger`; supply the surface inside it, usually a `Card`. */ const Popup = React.forwardRef(function PopoverPopup( - { className, style, size = 'md', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest }, + { anchor, className, style, size = 'md', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest }, ref, ) { return ( @@ -136,6 +138,7 @@ const Popup = React.forwardRef(function Popov it alone. */} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index b869b0742e6..6182d413050 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, @@ -54,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'; 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: '*'