diff --git a/.changeset/tidy-phone-input-spacing.md b/.changeset/tidy-phone-input-spacing.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/tidy-phone-input-spacing.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/components/phone-input/phone-input.feature.test.tsx b/packages/mosaic/src/components/phone-input/phone-input.feature.test.tsx
new file mode 100644
index 00000000000..e61bcc59bea
--- /dev/null
+++ b/packages/mosaic/src/components/phone-input/phone-input.feature.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it } from 'vitest';
+
+import { MosaicProvider } from '../../mosaic-provider';
+import { PhoneInput } from './phone-input';
+
+describe('PhoneInput country picker alignment', () => {
+ it.each(['sm', 'md', 'lg'] as const)('aligns search text and country flags for a %s input', async size => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+ const trigger = screen.getByRole('button', { name: 'Country, United States' });
+ await user.click(trigger);
+ const search = screen.getByRole('combobox', { name: 'Search countries' });
+ await waitFor(() => expect(search).toHaveFocus());
+ const country = screen.getByRole('option', { name: /United States/ });
+ const countryName = document.createRange();
+ countryName.selectNodeContents(within(country).getByText('United States'));
+ const triggerFlag = document.createRange();
+ triggerFlag.selectNodeContents(within(trigger).getByText('πΊπΈ'));
+ const countryFlag = document.createRange();
+ countryFlag.selectNodeContents(within(country).getByText('πΊπΈ'));
+
+ const searchTextLeft =
+ search.getBoundingClientRect().left + Number.parseFloat(getComputedStyle(search).paddingLeft);
+ expect(search.getBoundingClientRect().width).toBeGreaterThan(0);
+ expect.soft(Math.abs(searchTextLeft - countryName.getBoundingClientRect().left)).toBeLessThanOrEqual(0.5);
+ expect
+ .soft(Math.abs(triggerFlag.getBoundingClientRect().left - countryFlag.getBoundingClientRect().left))
+ .toBeLessThanOrEqual(0.5);
+ });
+});
diff --git a/packages/mosaic/src/components/phone-input/phone-input.styles.ts b/packages/mosaic/src/components/phone-input/phone-input.styles.ts
index 23a6f199762..2e8e571ca04 100644
--- a/packages/mosaic/src/components/phone-input/phone-input.styles.ts
+++ b/packages/mosaic/src/components/phone-input/phone-input.styles.ts
@@ -1,23 +1,48 @@
import * as stylex from '@stylexjs/stylex';
-import { colorVars, radiusVars, shadowVars, space, typeScaleVars } from '../../tokens.stylex';
+import {
+ colorVars,
+ fontFamilyVars,
+ fontWeightVars,
+ radiusVars,
+ shadowVars,
+ space,
+ typeScaleVars,
+} from '../../tokens.stylex';
export const styles = stylex.create({
+ triggerStart: {
+ paddingInlineStart: 'var(--_cl-input-group-inset)',
+ },
+ trigger: {
+ '--_cl-icon-color': colorVars['--cl-color-foreground-secondary'],
+ borderRadius: 'max(0px, calc(var(--_cl-input-group-radius) - var(--_cl-input-group-inset)))',
+ borderWidth: 0,
+ alignItems: 'center',
+ appearance: 'none',
+ backgroundColor: 'transparent',
+ cursor: 'pointer',
+ display: 'inline-flex',
+ flexShrink: 0,
+ justifyContent: 'center',
+ outlineOffset: { default: null, ':focus-visible': 0 },
+ paddingInlineEnd: 0,
+ paddingInlineStart: space['1.5'],
+ userSelect: 'none',
+ height: 'calc(var(--_cl-input-group-height) - 2 * var(--_cl-input-group-inset))',
+ },
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'],
+ fontSize: typeScaleVars['--cl-text-xs-size'],
justifyContent: 'center',
+ lineHeight: 1,
height: space['4'],
width: space['4'],
},
@@ -29,14 +54,14 @@ export const styles = stylex.create({
},
prefix: {
gap: space['2'],
- color: colorVars['--cl-color-brand'],
+ color: colorVars['--cl-color-foreground'],
fontVariantNumeric: 'tabular-nums',
paddingInlineEnd: 0,
paddingInlineStart: space['2'],
},
control: {
fontVariantNumeric: 'tabular-nums',
- paddingInlineStart: space['2'],
+ paddingInlineStart: space['1.5'],
},
popup: {
borderRadius: radiusVars['--cl-radius-lg'],
@@ -45,28 +70,129 @@ export const styles = stylex.create({
boxShadow: shadowVars['--cl-shadow-md'],
color: colorVars['--cl-color-foreground'],
},
- countrySearchContainer: {
- paddingInline: space['2'],
+ countrySearchRow: {
+ gap: space['1'],
+ alignItems: 'center',
+ borderBlockEndColor: colorVars['--cl-color-border'],
+ borderBlockEndStyle: 'solid',
+ borderBlockEndWidth: '1px',
+ display: 'flex',
+ flexShrink: 0,
+ paddingInlineEnd: space['2'],
+ paddingInlineStart: space['2'],
+ height: space['8'],
+ },
+ countrySearchIcon: {
+ alignItems: 'center',
+ display: 'flex',
+ flexShrink: 0,
+ justifyContent: 'center',
+ height: space['5'],
+ width: space['5'],
+ },
+ countrySearchInput: {
+ borderRadius: 0,
+ outline: {
+ default: 'none',
+ '@media (forced-colors: active)': {
+ default: null,
+ ':focus-visible': '2px solid CanvasText',
+ },
+ },
+ outlineOffset: {
+ default: null,
+ '@media (forced-colors: active)': {
+ default: null,
+ ':focus-visible': '2px',
+ },
+ },
+ paddingInlineEnd: space['1'],
+ paddingInlineStart: space['0.5'],
+ height: space['6'],
+ '::placeholder': {
+ color: colorVars['--cl-color-foreground-secondary'],
+ },
+ },
+ countryList: {
+ gap: space['0.5'],
+ paddingBlock: space['0.5'],
+ paddingInline: space['1'],
+ display: 'flex',
+ flexDirection: 'column',
+ maxHeight: '16rem',
+ },
+ option: {
+ borderRadius: radiusVars['--cl-radius-md'],
+ gap: space['1'],
+ outline: {
+ default: 'none',
+ '@media (forced-colors: active)': {
+ default: null,
+ ':is([data-active])': '2px solid CanvasText',
+ },
+ },
+ paddingBlock: space['1'],
+ alignItems: 'center',
+ backgroundColor: {
+ default: 'transparent',
+ ':is([data-active])': colorVars['--cl-color-neutral-alpha-100'],
+ '@media (hover: hover)': {
+ ':hover': colorVars['--cl-color-neutral-alpha-100'],
+ },
+ },
+ color: {
+ default: colorVars['--cl-color-foreground-secondary'],
+ ':where([data-active])': colorVars['--cl-color-foreground'],
+ },
+ cursor: 'pointer',
+ display: 'flex',
flexShrink: 0,
- paddingBlockEnd: space['1'],
- paddingBlockStart: space['2'],
+ fontFamily: fontFamilyVars['--cl-font-family-sans'],
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ fontWeight: fontWeightVars['--cl-font-normal'],
+ lineHeight: typeScaleVars['--cl-text-sm-leading'],
+ paddingInlineEnd: space['1.5'],
+ paddingInlineStart: space['1'],
+ height: space['8'],
+ },
+ empty: {
+ paddingBlock: space['6'],
+ paddingInline: space['3'],
+ color: colorVars['--cl-color-foreground-secondary'],
+ fontFamily: fontFamilyVars['--cl-font-family-sans'],
+ fontSize: typeScaleVars['--cl-text-sm-size'],
+ textAlign: 'center',
+ },
+ optionFlag: {
+ alignItems: 'center',
+ display: 'flex',
+ flexShrink: 0,
+ justifyContent: 'center',
+ height: space['5'],
+ width: space['5'],
},
optionName: {
+ padding: space['0.5'],
overflow: 'hidden',
flexGrow: 1,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
+ height: space['6'],
minWidth: 0,
},
optionCode: {
+ alignItems: 'center',
color: colorVars['--cl-color-foreground-secondary'],
+ display: 'flex',
flexShrink: 0,
+ fontSize: typeScaleVars['--cl-text-xs-size'],
fontVariantNumeric: 'tabular-nums',
+ fontWeight: fontWeightVars['--cl-font-medium'],
+ justifyContent: 'flex-end',
+ lineHeight: typeScaleVars['--cl-text-xs-leading'],
whiteSpace: 'nowrap',
- },
- indicatorSlot: {
- display: 'flex',
- flexShrink: 0,
- width: space['3'],
+ height: space['5'],
+ minWidth: space['5'],
+ width: 'max-content',
},
});
diff --git a/packages/mosaic/src/components/phone-input/phone-input.test.tsx b/packages/mosaic/src/components/phone-input/phone-input.test.tsx
index 5643f0358a3..0e690a23b15 100644
--- a/packages/mosaic/src/components/phone-input/phone-input.test.tsx
+++ b/packages/mosaic/src/components/phone-input/phone-input.test.tsx
@@ -7,6 +7,22 @@ import { Field } from '../field';
import { PhoneInput } from './phone-input';
describe('Mosaic PhoneInput', () => {
+ it('opens the country picker from the keyboard and restores focus when dismissed', async () => {
+ const user = userEvent.setup();
+ render();
+ const trigger = screen.getByRole('button', { name: 'Country, United States' });
+
+ await user.tab();
+ expect(trigger).toHaveFocus();
+ expect(trigger).toHaveTextContent('πΊπΈ');
+ await user.keyboard('{Enter}');
+ await waitFor(() => expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveFocus());
+ await user.keyboard('{Escape}');
+
+ await waitFor(() => expect(trigger).toHaveFocus());
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ });
+
it('focuses the country search when opened with a pointer', async () => {
const user = userEvent.setup();
render();
@@ -16,6 +32,29 @@ describe('Mosaic PhoneInput', () => {
await waitFor(() => expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveFocus());
});
+ it('shows Search as the default country search placeholder', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Country, United States' }));
+
+ expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('placeholder', 'Search');
+ });
+
+ it('uses a custom country search placeholder', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Country, United States' }));
+
+ expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('placeholder', 'Find a country');
+ });
+
it('forwards object refs and clears them on unmount', () => {
const ref = React.createRef();
const { unmount } = render(
@@ -57,16 +96,28 @@ describe('Mosaic PhoneInput', () => {
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.each(['sm', 'md', 'lg'] as const)(
+ 'opens and dismisses the country picker with Space for a %s input',
+ async size => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ const trigger = screen.getByRole('button', { name: 'Country, United States' });
+
+ await user.tab();
+ expect(trigger).toHaveFocus();
+ await user.keyboard(' ');
+ await waitFor(() => expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveFocus());
+ await user.keyboard('{Escape}');
+
+ await waitFor(() => expect(trigger).toHaveFocus());
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ },
+ );
it('renders one grouped telephone control with the default country', () => {
render();
@@ -76,10 +127,8 @@ 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-button', 'cl-phone-input-country-trigger');
+ expect(countryTrigger).toHaveClass('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');
@@ -151,7 +200,7 @@ describe('Mosaic PhoneInput', () => {
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');
+ expect(searchInput.closest('.cl-phone-input-country-search')).toBeInTheDocument();
await user.type(searchInput, 'Greece');
expect(screen.getByRole('listbox')).toHaveClass('cl-combobox-list');
expect(screen.getByRole('option', { name: /Greece/ })).toHaveClass('cl-combobox-option');
@@ -189,6 +238,63 @@ describe('Mosaic PhoneInput', () => {
expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveValue('');
});
+ it('links the country search to its list and active option through selection', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Country, United States' }));
+ const searchInput = screen.getByRole('combobox', { name: 'Search countries' });
+ const list = screen.getByRole('listbox');
+ const option = screen.getByRole('option', { name: /United Kingdom/ });
+ expect(list.id).toBeTruthy();
+ expect(searchInput).toHaveAttribute('aria-controls', list.id);
+
+ await user.keyboard('{ArrowDown}');
+ expect(option.id).toBeTruthy();
+ expect(searchInput).toHaveAttribute('aria-activedescendant', option.id);
+
+ await user.keyboard('{Enter}');
+ expect(screen.getByRole('button', { name: 'Country, United Kingdom' })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveFocus();
+ });
+
+ it('focuses the search when clicking its row after dismissing and reopening', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Country, United States' }));
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByRole('combobox', { name: 'Search countries' })).not.toBeInTheDocument());
+ await user.click(screen.getByRole('button', { name: 'Country, United States' }));
+
+ const searchInput = screen.getByRole('combobox', { name: 'Search countries' });
+ const searchRow = searchInput.closest('.cl-phone-input-country-search');
+ if (!searchRow) {
+ throw new Error('Country search row missing');
+ }
+ searchInput.blur();
+ await user.click(searchRow);
+
+ expect(searchInput).toHaveFocus();
+ });
+
+ it('focuses the phone input when clicking its group after the search is dismissed', async () => {
+ const user = userEvent.setup();
+ render();
+ const phoneInput = screen.getByRole('textbox', { name: 'Phone number' });
+ const phoneGroup = phoneInput.closest('.cl-phone-input');
+ if (!phoneGroup) {
+ throw new Error('Phone input group missing');
+ }
+
+ await user.click(screen.getByRole('button', { name: 'Country, United States' }));
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByRole('combobox', { name: 'Search countries' })).not.toBeInTheDocument());
+ await user.click(phoneGroup);
+
+ expect(phoneInput).toHaveFocus();
+ });
+
it('renders the country list inside the popup', async () => {
const user = userEvent.setup();
render();
diff --git a/packages/mosaic/src/components/phone-input/phone-input.tsx b/packages/mosaic/src/components/phone-input/phone-input.tsx
index 3ca0d4386cf..a3ac6dd7001 100644
--- a/packages/mosaic/src/components/phone-input/phone-input.tsx
+++ b/packages/mosaic/src/components/phone-input/phone-input.tsx
@@ -8,17 +8,21 @@ import React from 'react';
import { ChevronDown } from '../../icons/glyphs/chevron-down';
import { MagnifyingGlass } from '../../icons/glyphs/magnifying-glass';
+import { Button } from '../../primitives/button';
+import { Combobox as HeadlessCombobox } from '../../primitives/combobox';
import { useFormattedInput } from '../../primitives/hooks';
import type { MosaicElementProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
+import { focusOutline } from '../../styles/focus-outline.styles';
import { reset } from '../../styles/reset.styles';
-import { Button } from '../button';
-import { Combobox } from '../combobox';
+import { styles as buttonStyles } from '../button/button.styles';
import { Field } from '../field';
import { useOptionalFieldContext } from '../field/field.context';
import { Icon } from '../icon';
+import { sizes as inputSizes, styles as inputStyles } from '../input/input.styles';
import { InputGroup } from '../input-group';
import { Popover } from '../popover';
+import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area';
import { formatNationalNumber, getNationalNumber, toE164 } from './phone-input.format';
import { styles } from './phone-input.styles';
@@ -75,7 +79,7 @@ export const PhoneInput = React.forwardRef(fu
defaultCountry,
onCountryChange,
size = 'md',
- countrySearchPlaceholder = 'Search country or code',
+ countrySearchPlaceholder = 'Search',
noResultsMessage = 'No countries found',
disabled: disabledProp,
required: requiredProp,
@@ -201,13 +205,20 @@ export const PhoneInput = React.forwardRef(fu
placement='bottom-start'
sideOffset={8}
>
-
+
}
type='button'
@@ -217,10 +228,7 @@ export const PhoneInput = React.forwardRef(fu
{getFlagEmojiFromCountryIso(country.iso)}
@@ -238,7 +246,7 @@ export const PhoneInput = React.forwardRef(fu
{...themeProps('phone-input-popup')}
xstyle={[reset.base, styles.popup]}
>
- (fu
}
}}
>
-
-
+
+
-
+
{filteredCountries.length > 0 ? (
filteredCountries.map(option => (
-
- {getFlagEmojiFromCountryIso(option.iso)}
+ {getFlagEmojiFromCountryIso(option.iso)}
{option.name}
+{option.code}
-
-
-
-
+
))
) : (
- {noResultsMessage}
+
+ {noResultsMessage}
+
)}
-
-
+
+
diff --git a/packages/swingset/src/stories/phone-input.mdx b/packages/swingset/src/stories/phone-input.mdx
index 06eac3dd7f9..17a00e6e74d 100644
--- a/packages/swingset/src/stories/phone-input.mdx
+++ b/packages/swingset/src/stories/phone-input.mdx
@@ -24,7 +24,7 @@ It composes `InputGroup` for the telephone field and the inline `Combobox` compo
{ 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: 'countrySearchPlaceholder', type: 'string', default: "'Search'" },
{ name: 'noResultsMessage', type: 'string', default: "'No countries found'" },
]}
/>
diff --git a/packages/swingset/src/stories/phone-input.stories.tsx b/packages/swingset/src/stories/phone-input.stories.tsx
index a5b554fb9c6..5f92db47b93 100644
--- a/packages/swingset/src/stories/phone-input.stories.tsx
+++ b/packages/swingset/src/stories/phone-input.stories.tsx
@@ -13,7 +13,7 @@ export { default as __source } from './phone-input.stories?raw';
export const meta: StoryMeta = {
group: 'Components',
- status: 'wip',
+ status: 'stable',
title: 'PhoneInput',
source: 'packages/mosaic/src/components/phone-input/phone-input.tsx',
styles: {