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 @@ -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 => ( - {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: {